Ingatlanok tulajdoni lapok és tulajdonosok táblázatos és térképi megjelenítése.
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 6s

This commit is contained in:
2026-08-31 20:38:18 +02:00
parent 443b35fbb9
commit 47f7687e40
13 changed files with 1599 additions and 26 deletions
+16 -19
View File
@@ -190,25 +190,22 @@ class DeviceIdentityService extends GetxService {
final user = Supabase.instance.client.auth.currentUser;
if (user == null || deviceId.isEmpty) return;
await Supabase.instance.client.from('terepi_seged_devices').upsert({
'id': appInstanceId,
'user_id': user.id,
'name': info.systemDeviceName,
'device_id': deviceId,
'platform': info.platform,
'model': model,
'os_version': info.osVersion,
'app_version': appInfo,
'last_seen_at': DateTime.now().toUtc().toIso8601String()
});
//
// // await Supabase.instance.client
// .from('devices')
// .upsert(
// info.toRegistrationMap(label: deviceLabel.value),
// onConflict: 'device_id',
// );
try {
await Supabase.instance.client.from('terepi_seged_devices').upsert({
'id': appInstanceId,
'user_id': user.id,
'name': info.systemDeviceName,
'device_id': deviceId,
'platform': info.platform,
'model': model,
'os_version': info.osVersion,
'app_version': appInfo,
'last_seen_at': DateTime.now().toUtc().toIso8601String()
});
} catch (e) {
AppLogger.e('DeviceIdentityService.registerDevice',
'Eszköz-regisztráció hiba (user=${user.id} - device=$deviceId): $e');
}
}
// ── Gyors elérők (kényelemért) ────────────────────────────────────
+98
View File
@@ -0,0 +1,98 @@
import 'package:get/get.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/field_party.dart';
import '../models/field_property.dart';
/// Ingatlan-nyilvántartás lekérdezései — SZÁNDÉKOSAN tisztán online,
/// nincs helyi outbox/tükör-tábla (a kapcsolatokkal/mérésekkel
/// ellentétben). Ez az adat nem kerülhet a készülék adatbázisába.
class FieldPropertyService extends GetxService {
static FieldPropertyService get to => Get.find();
SupabaseClient get _client => Supabase.instance.client;
/// Ingatlanok listája, kereséssel/szűréssel.
Future<List<FieldProperty>> listProperties({
required String projectId,
String? search,
String? settlement,
String? mailingStatus,
}) async {
var query = _client
.from('terepi_seged_field_properties_view')
.select()
.eq('project_id', projectId);
if (settlement != null && settlement.isNotEmpty) {
query = query.eq('settlement', settlement);
}
if (mailingStatus != null && mailingStatus.isNotEmpty) {
query = query.eq('mailing_status', mailingStatus);
}
if (search != null && search.trim().isNotEmpty) {
final s = search.trim().replaceAll(',', ' ');
query = query.or(
'parcel_number.ilike.%$s%,owner_names.ilike.%$s%,settlement.ilike.%$s%',
);
}
final rows = await query
.order('settlement', ascending: true)
.order('parcel_number', ascending: true)
.limit(1000);
return rows.map((r) => FieldProperty.fromMap(r)).toList();
}
/// Tulajdonosok keresése név szerint.
Future<List<FieldParty>> searchParties({
required String projectId,
String? search,
}) async {
var query = _client
.from('terepi_seged_field_parties_view')
.select()
.eq('project_id', projectId);
if (search != null && search.trim().isNotEmpty) {
query = query.ilike('name', '%${search.trim()}%');
}
final rows = await query.order('name', ascending: true).limit(1000);
return rows.map((r) => FieldParty.fromMap(r)).toList();
}
/// Egy adott tulajdonos ÖSSZES ingatlana.
Future<List<FieldProperty>> listPropertiesForParty(String partyId) async {
final links = await _client
.from('terepi_seged_field_property_parties')
.select('property_id')
.eq('party_id', partyId)
.eq('is_current', true);
final propertyIds =
links.map((r) => r['property_id'] as String).toSet().toList();
if (propertyIds.isEmpty) return [];
final rows = await _client
.from('terepi_seged_field_properties_view')
.select()
.inFilter('id', propertyIds)
.order('settlement')
.order('parcel_number');
return rows.map((r) => FieldProperty.fromMap(r)).toList();
}
/// Egy ingatlan tulajdonosi/kezelői bontása, tulajdoni hányaddal — a
/// részletnézethez.
Future<List<Map<String, dynamic>>> propertyOwnershipDetail(
String propertyId) async {
final rows = await _client
.from('terepi_seged_field_property_parties')
.select('*, terepi_seged_field_parties(name, party_type)')
.eq('property_id', propertyId)
.eq('is_current', true);
return List<Map<String, dynamic>>.from(rows);
}
}
+241
View File
@@ -0,0 +1,241 @@
import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import '../models/field_property.dart';
class GeoJsonImportResult {
final int imported;
final List<String> skipped;
const GeoJsonImportResult({required this.imported, required this.skipped});
}
/// Ingatlan-parcellák geometriája (GeoJSON-import + térképi lekérdezés).
///
/// SZÁNDÉKOSAN tisztán online, nincs helyi tükör-tábla. A tábla
/// PROJEKT-FÜGGETLEN, közös katalógus — egy fizikai telek geometriája
/// nem egyetlen projekthez tartozik.
class ParcelGeometryService extends GetxService {
static ParcelGeometryService get to => Get.find();
SupabaseClient get _client => Supabase.instance.client;
// ═════════════════════════════════════════════════════════════════
// Normalizálás — PONTOSAN a levél-pipeline szabálya (ellenőrzött
// minta alapján): minden "/"-lel elválasztott számjegy-szegmensről
// levágja a vezető nullákat.
// "0173/4" → "173/4", "0620" → "620", "011/9" → "11/9"
// ═════════════════════════════════════════════════════════════════
static String normalizeParcelNumber(String raw) {
final trimmed = raw.trim();
final parts = trimmed.split('/');
final normalized = parts.map((p) {
final stripped = p.replaceFirst(RegExp(r'^0+(?=\d)'), '');
return stripped.isEmpty ? '0' : stripped;
}).join('/');
return normalized;
}
// ═════════════════════════════════════════════════════════════════
// Import
// ═════════════════════════════════════════════════════════════════
static const _settlementKeys = ['telepules', 'település', 'settlement'];
static const _parcelKeys = [
'hrsz',
'helyrajziszam',
'helyrajzi_szam',
'parcel_number',
];
static String? _extractByKeys(Map<String, dynamic> props, List<String> keys) {
final lower = {for (final e in props.entries) e.key.toLowerCase(): e.value};
for (final k in keys) {
final v = lower[k];
if (v != null && v.toString().trim().isNotEmpty)
return v.toString().trim();
}
return null;
}
/// A GeoJSON tartalmának importja — Feature-önként upsert, egy hibás
/// elem nem akasztja meg a többit.
Future<GeoJsonImportResult> importGeoJson(String content,
{required String projectId, String? sourceFileName}) async {
final dynamic json = jsonDecode(content);
final features = (json is Map && json['features'] is List)
? json['features'] as List
: <dynamic>[];
var imported = 0;
final skipped = <String>[];
for (final f in features) {
try {
if (f is! Map) {
skipped.add('érvénytelen elem');
continue;
}
final geometry = f['geometry'];
if (geometry is! Map) {
skipped.add('geometria nélküli elem');
continue;
}
final geomType = geometry['type'] as String?;
List coords;
if (geomType == 'MultiPolygon') {
coords = geometry['coordinates'] as List;
} else if (geomType == 'Polygon') {
// Egységesítjük MultiPolygon-alakúra tároláshoz, hogy a
// megjelenítés mindig ugyanazt a szerkezetet olvassa.
coords = [geometry['coordinates']];
} else {
skipped.add('nem támogatott geometria-típus: $geomType');
continue;
}
final props = (f['properties'] as Map?)?.cast<String, dynamic>() ?? {};
final settlement = _extractByKeys(props, _settlementKeys);
final rawParcel = _extractByKeys(props, _parcelKeys);
if (settlement == null || rawParcel == null) {
skipped.add(
'hiányzó település/helyrajzi szám (talált kulcsok: ${props.keys.join(", ")})');
continue;
}
final normalized = normalizeParcelNumber(rawParcel);
final bbox = _computeBbox(coords);
await _client.from('terepi_seged_field_parcel_geometries').upsert({
'project_id': projectId,
'settlement': settlement,
'normalized_parcel_number': normalized,
'parcel_number_raw': rawParcel,
'geometry': {'type': 'MultiPolygon', 'coordinates': coords},
'bbox_min_lat': bbox.minLat,
'bbox_max_lat': bbox.maxLat,
'bbox_min_lon': bbox.minLon,
'bbox_max_lon': bbox.maxLon,
'source_file': sourceFileName,
}, onConflict: 'project_id,settlement,normalized_parcel_number');
imported++;
} catch (e) {
skipped.add('hiba: $e');
}
}
return GeoJsonImportResult(imported: imported, skipped: skipped);
}
_Bbox _computeBbox(List multiPolygonCoords) {
double minLat = double.infinity, maxLat = -double.infinity;
double minLon = double.infinity, maxLon = -double.infinity;
for (final polygon in multiPolygonCoords) {
for (final ring in polygon as List) {
for (final point in ring as List) {
final lon = (point[0] as num).toDouble();
final lat = (point[1] as num).toDouble();
if (lat < minLat) minLat = lat;
if (lat > maxLat) maxLat = lat;
if (lon < minLon) minLon = lon;
if (lon > maxLon) maxLon = lon;
}
}
}
return _Bbox(
minLat: minLat, maxLat: maxLat, minLon: minLon, maxLon: maxLon);
}
// ═════════════════════════════════════════════════════════════════
// Lekérdezés a térképi megjelenítéshez
// ═════════════════════════════════════════════════════════════════
/// Egy vagy több ingatlan geometriája (settlement + normalized_parcel_number
/// párok alapján) — a property-lista bármely részhalmazára hívható.
Future<List<Map<String, dynamic>>> fetchGeometriesFor(
List<FieldProperty> properties) async {
if (properties.isEmpty) return [];
// Minden átadott property ugyanahhoz az (aktív) projekthez tartozik —
// a geometria is erre a projektre szűkül.
final projectId = properties.first.projectId;
final bySettlement = <String, Set<String>>{};
for (final p in properties) {
bySettlement
.putIfAbsent(p.settlement, () => {})
.add(p.normalizedParcelNumber);
}
final result = <Map<String, dynamic>>[];
for (final entry in bySettlement.entries) {
final rows = await _client
.from('terepi_seged_field_parcel_geometries')
.select()
.eq('project_id', projectId)
.eq('settlement', entry.key)
.inFilter('normalized_parcel_number', entry.value.toList());
result.addAll(List<Map<String, dynamic>>.from(rows));
}
return result;
}
// ═════════════════════════════════════════════════════════════════
// flutter_map segédek
// ═════════════════════════════════════════════════════════════════
List<Polygon> parsePolygons(List<Map<String, dynamic>> rows,
{Color color = Colors.deepOrange}) {
final polygons = <Polygon>[];
for (final row in rows) {
final geom = row['geometry'] as Map;
final coords = geom['coordinates'] as List; // MultiPolygon
for (final polygon in coords) {
final outerRing = (polygon as List).first as List;
final points = outerRing
.map((p) =>
LatLng((p[1] as num).toDouble(), (p[0] as num).toDouble()))
.toList();
polygons.add(Polygon(
points: points,
color: color.withOpacity(0.35),
borderColor: color,
borderStrokeWidth: 2,
));
}
}
return polygons;
}
LatLngBounds? computeBounds(List<Map<String, dynamic>> rows) {
if (rows.isEmpty) return null;
double minLat = double.infinity, maxLat = -double.infinity;
double minLon = double.infinity, maxLon = -double.infinity;
for (final row in rows) {
minLat = math.min(minLat, (row['bbox_min_lat'] as num).toDouble());
maxLat = math.max(maxLat, (row['bbox_max_lat'] as num).toDouble());
minLon = math.min(minLon, (row['bbox_min_lon'] as num).toDouble());
maxLon = math.max(maxLon, (row['bbox_max_lon'] as num).toDouble());
}
return LatLngBounds(LatLng(minLat, minLon), LatLng(maxLat, maxLon));
}
}
class _Bbox {
final double minLat, maxLat, minLon, maxLon;
const _Bbox({
required this.minLat,
required this.maxLat,
required this.minLon,
required this.maxLon,
});
}