diff --git a/android/gradle.properties b/android/gradle.properties index eac5ff9..cd4198d 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,6 +1,6 @@ org.gradle.jvmargs=-Xmx1536M android.useAndroidX=true -android.enableJetifier=true +android.enableJetifier=false # This builtInKotlin flag was added automatically by Flutter migrator android.builtInKotlin=false # This newDsl flag was added automatically by Flutter migrator diff --git a/lib/main.dart b/lib/main.dart index 63eab25..e7a5cb1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,6 +15,7 @@ import 'package:terepi_seged/services/auth_service.dart'; import 'package:terepi_seged/services/contact_service.dart'; import 'package:terepi_seged/services/coord_converter_service.dart'; import 'package:terepi_seged/services/device_identity_service.dart'; +import 'package:terepi_seged/services/field_property_service.dart'; import 'package:terepi_seged/services/firebase_logger.dart'; import 'package:terepi_seged/services/gnss/gnss_device_service.dart'; import 'package:terepi_seged/services/gnss/gnss_service.dart'; @@ -23,6 +24,7 @@ import 'package:terepi_seged/services/layer_sync_service.dart'; import 'package:terepi_seged/services/note_audio_service.dart'; import 'package:terepi_seged/services/note_photo_service.dart'; import 'package:terepi_seged/services/ntrip_service.dart'; +import 'package:terepi_seged/services/parcel_geometry_service.dart'; import 'package:terepi_seged/services/permission_service.dart'; import 'package:terepi_seged/services/project_service.dart'; import 'package:terepi_seged/services/stakeout_service.dart'; @@ -113,6 +115,8 @@ Future main() async { Get.put(PermissionService()); Get.put(ContactService()); Get.put(VehicleIdentityService()); + Get.put(FieldPropertyService()); + Get.put(ParcelGeometryService()); runApp(const MyApp()); } diff --git a/lib/models/field_party.dart b/lib/models/field_party.dart new file mode 100644 index 0000000..86f63f7 --- /dev/null +++ b/lib/models/field_party.dart @@ -0,0 +1,28 @@ +/// Egy tulajdonos/kezelő a levél-pipeline importjából — tisztán online, +/// nincs helyi tükör. +class FieldParty { + final String id; + final String publicationId; + final String name; + final String partyType; + final String? settlement; + final int propertyCount; + + const FieldParty({ + required this.id, + required this.publicationId, + required this.name, + required this.partyType, + this.settlement, + required this.propertyCount, + }); + + factory FieldParty.fromMap(Map m) => FieldParty( + id: m['id'] as String, + publicationId: m['publication_id'] as String, + name: m['name'] as String? ?? '', + partyType: m['party_type'] as String? ?? '', + settlement: m['settlement'] as String?, + propertyCount: (m['property_count'] as num?)?.toInt() ?? 0, + ); +} diff --git a/lib/models/field_property.dart b/lib/models/field_property.dart new file mode 100644 index 0000000..c186aac --- /dev/null +++ b/lib/models/field_property.dart @@ -0,0 +1,69 @@ +/// Egy ingatlan (helyrajzi szám) a levél-pipeline importjából. +/// +/// SZÁNDÉKOSAN nincs hozzá helyi tábla/toMap — ez a funkció tisztán +/// online, a `terepi_seged_field_properties_view` élő lekérdezéséből él, +/// sosem kerül a készülék adatbázisába. +class FieldProperty { + final String id; + final String projectId; + final String publicationId; + final String settlement; + final String parcelNumber; + final String normalizedParcelNumber; + final String areaType; + final String mailingStatus; + final int? totalAreaSquareMeters; + final double? totalCadastralIncome; + final String? caseIdentifier; + final int recipientCount; + final int selectedRecipientCount; + final int generatedLetterCount; + final int sentLetterCount; + final String ownerNames; + final String landUseSummary; + final DateTime createdAt; + + const FieldProperty({ + required this.id, + required this.projectId, + required this.publicationId, + required this.settlement, + required this.parcelNumber, + required this.normalizedParcelNumber, + required this.areaType, + required this.mailingStatus, + this.totalAreaSquareMeters, + this.totalCadastralIncome, + this.caseIdentifier, + required this.recipientCount, + required this.selectedRecipientCount, + required this.generatedLetterCount, + required this.sentLetterCount, + required this.ownerNames, + this.landUseSummary = '', + required this.createdAt, + }); + + factory FieldProperty.fromMap(Map m) => FieldProperty( + id: m['id'] as String, + projectId: m['project_id'] as String, + publicationId: m['publication_id'] as String, + settlement: m['settlement'] as String? ?? '', + parcelNumber: m['parcel_number'] as String? ?? '', + normalizedParcelNumber: m['normalized_parcel_number'] as String? ?? '', + areaType: m['area_type'] as String? ?? '', + mailingStatus: m['mailing_status'] as String? ?? 'unknown', + totalAreaSquareMeters: (m['total_area_square_meters'] as num?)?.toInt(), + totalCadastralIncome: (m['total_cadastral_income'] as num?)?.toDouble(), + caseIdentifier: m['case_identifier'] as String?, + recipientCount: (m['recipient_count'] as num?)?.toInt() ?? 0, + selectedRecipientCount: + (m['selected_recipient_count'] as num?)?.toInt() ?? 0, + generatedLetterCount: + (m['generated_letter_count'] as num?)?.toInt() ?? 0, + sentLetterCount: (m['sent_letter_count'] as num?)?.toInt() ?? 0, + ownerNames: m['owner_names'] as String? ?? '', + landUseSummary: m['land_use_summary'] as String? ?? '', + createdAt: DateTime.parse(m['created_at'] as String), + ); +} diff --git a/lib/models/project.dart b/lib/models/project.dart index 7f86613..e596356 100644 --- a/lib/models/project.dart +++ b/lib/models/project.dart @@ -90,6 +90,17 @@ class Project { createdAt: DateTime.parse(m['created_at'] as String), updatedAt: DateTime.parse(m['updated_at'] as String), ); + + @override + bool operator ==(Object other) => + identical(this, other) || + other is Project && + runtimeType == other.runtimeType && + id == other.id && + updatedAt == other.updatedAt; + + @override + int get hashCode => Object.hash(id, updatedAt); } bool _readBool( diff --git a/lib/pages/contacts/presentation/views/contacts_view.dart b/lib/pages/contacts/presentation/views/contacts_view.dart index 87ccc32..af0d52c 100644 --- a/lib/pages/contacts/presentation/views/contacts_view.dart +++ b/lib/pages/contacts/presentation/views/contacts_view.dart @@ -26,7 +26,8 @@ class ContactsView extends StatelessWidget { Widget build(BuildContext context) { // Jogosultság-kapu — ha nincs joga, be sem töltjük a listát. if (Get.isRegistered() && - !PermissionService.to.canContacts) { + !PermissionService.to.canContacts( + projectId: ProjectService.to.activeProject.value?.uuid)) { return Scaffold( appBar: AppBar(title: const Text('Kapcsolatok')), body: const _NoAccess(), diff --git a/lib/pages/property_list/presentations/controllers/property_list_controller.dart b/lib/pages/property_list/presentations/controllers/property_list_controller.dart index 31c7aa7..cff258a 100644 --- a/lib/pages/property_list/presentations/controllers/property_list_controller.dart +++ b/lib/pages/property_list/presentations/controllers/property_list_controller.dart @@ -1,3 +1,263 @@ -import 'package:get/get.dart'; +import 'dart:async'; +import 'dart:io'; -class PropertyListController extends GetxController {} +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:terepi_seged/models/field_party.dart'; +import 'package:terepi_seged/models/field_property.dart'; +import 'package:terepi_seged/pages/property_list/presentations/views/parcel_map_view.dart'; +import 'package:terepi_seged/services/field_property_service.dart'; +import 'package:terepi_seged/services/parcel_geometry_service.dart'; +import 'package:terepi_seged/services/project_service.dart'; + +enum FieldViewMode { properties, parties } + +class PropertyListController extends GetxController { + static PropertyListController get to => Get.find(); + + final viewMode = FieldViewMode.properties.obs; + + // ── Ingatlanok fül ─────────────────────────────────────────────── + final searchText = ''.obs; + final settlementFilter = Rxn(); + final mailingStatusFilter = Rxn(); + final isLoading = false.obs; + final properties = [].obs; + final settlements = [].obs; + + /// Ha nem null, a lista egy KIVÁLASZTOTT tulajdonos ingatlanjait + /// mutatja, nem az általános keresést/szűrést. + final selectedParty = Rxn(); + + // ── Tulajdonosok fül ───────────────────────────────────────────── + final partySearchText = ''.obs; + final isLoadingParties = false.obs; + final parties = [].obs; + + Timer? _propertyDebounce; + Timer? _partyDebounce; + final partySearchController = TextEditingController(); + + @override + @override + void onInit() { + super.onInit(); + loadProperties(); + searchParties(); // a tulajdonos-lista is azonnal töltődjön, ne csak gépelésre + ever(ProjectService.to.activeProject, (_) { + selectedParty.value = null; + loadProperties(); + searchParties(); + }); + } + + @override + void onClose() { + _propertyDebounce?.cancel(); + _partyDebounce?.cancel(); + partySearchController.dispose(); + super.onClose(); + } + + // ── Ingatlan-keresés/szűrés ────────────────────────────────────── + + void onSearchChanged(String v) { + searchText.value = v; + _propertyDebounce?.cancel(); + _propertyDebounce = + Timer(const Duration(milliseconds: 400), loadProperties); + } + + void setSettlementFilter(String? v) { + settlementFilter.value = v; + loadProperties(); + } + + void setMailingStatusFilter(String? v) { + mailingStatusFilter.value = v; + loadProperties(); + } + + Future loadProperties() async { + // Ha épp egy tulajdonosra van szűkítve a nézet, a sima keresés ne + // írja felül azt — a felhasználó explicit törli (clearPartySelection). + if (selectedParty.value != null) return; + + final projectId = ProjectService.to.activeProject.value?.uuid; + if (projectId == null) { + properties.clear(); + settlements.clear(); + return; + } + isLoading.value = true; + try { + final list = await FieldPropertyService.to.listProperties( + projectId: projectId, + search: searchText.value, + settlement: settlementFilter.value, + mailingStatus: mailingStatusFilter.value, + ); + properties.value = list; + // A település-szűrő lista a TELJES (szűretlen) körből épülne fel + // igazán jól — egyszerűség kedvéért most a jelenlegi találatokból + // számoljuk, ami induláskor (szűrés nélkül) helyes. + if (settlementFilter.value == null) { + settlements.value = list.map((p) => p.settlement).toSet().toList() + ..sort(); + } + } catch (e) { + Get.snackbar('Hiba', 'Nem sikerült betölteni az ingatlanokat: $e', + snackPosition: SnackPosition.BOTTOM); + } finally { + isLoading.value = false; + } + } + + // ── Tulajdonos-keresés ─────────────────────────────────────────── + + void onPartySearchChanged(String v) { + partySearchText.value = v; + _partyDebounce?.cancel(); + _partyDebounce = Timer(const Duration(milliseconds: 400), searchParties); + } + + Future searchParties() async { + final projectId = ProjectService.to.activeProject.value?.uuid; + if (projectId == null) return; + isLoadingParties.value = true; + try { + parties.value = await FieldPropertyService.to.searchParties( + projectId: projectId, + search: partySearchText.value, + ); + } catch (e) { + Get.snackbar('Hiba', 'Nem sikerült betölteni a tulajdonosokat: $e', + snackPosition: SnackPosition.BOTTOM); + } finally { + isLoadingParties.value = false; + } + } + + /// Determinisztikus törlés — nem várja meg a debounce-ot, azonnal + /// visszahozza a teljes (szűretlen) listát. + void clearPartySearch() { + partySearchController.clear(); + partySearchText.value = ''; + _partyDebounce?.cancel(); + searchParties(); + } + + /// Egy tulajdonos kiválasztása → átvált az Ingatlanok fülre, az ő + /// ingatlanjaival. + Future selectParty(FieldParty party) async { + selectedParty.value = party; + viewMode.value = FieldViewMode.properties; + isLoading.value = true; + try { + properties.value = + await FieldPropertyService.to.listPropertiesForParty(party.id); + } catch (e) { + Get.snackbar('Hiba', 'Nem sikerült betölteni az ingatlanokat: $e', + snackPosition: SnackPosition.BOTTOM); + } finally { + isLoading.value = false; + } + } + + void clearPartySelection() { + selectedParty.value = null; + viewMode.value = FieldViewMode.parties; + loadProperties(); + // A korábban a tulajdonos MEGTALÁLÁSÁHOZ begépelt keresőszöveg is + // törlődjön — különben a Tulajdonosok fülre visszaváltva a lista + // továbbra is arra az egy szűrt találatra maradna korlátozva. + clearPartySearch(); + } + + // ── GeoJSON import (geometria-katalógus) ──────────────────────────── + + Future importGeoJsonFile() async { + // FONTOS: nem FileType.custom + allowedExtensions — a .geojson nincs + // benne Android beépített MIME-táblázatában, emiatt felhős forrásoknál + // (Google Drive stb.) a fájlok szürkén, kiválaszthatatlanul jelennek + // meg. Helyette mindent engedünk, és utólag, kézzel ellenőrizzük a + // kiterjesztést. + final result = await FilePicker.platform.pickFiles(type: FileType.any); + if (result == null || result.files.single.path == null) return; + + final pickedName = result.files.single.name.toLowerCase(); + if (!pickedName.endsWith('.geojson') && !pickedName.endsWith('.json')) { + Get.snackbar('Nem támogatott fájl', + 'Válassz .geojson vagy .json kiterjesztésű fájlt.', + snackPosition: SnackPosition.BOTTOM); + return; + } + + final file = File(result.files.single.path!); + final content = await file.readAsString(); + + Get.dialog(const Center(child: CircularProgressIndicator()), + barrierDismissible: false); + try { + final projectId = ProjectService.to.activeProject.value?.uuid; + if (projectId == null) { + Get.back(); + Get.snackbar('Nincs aktív projekt', + 'Válassz aktív projektet az importálás előtt.', + snackPosition: SnackPosition.BOTTOM); + return; + } + final res = await ParcelGeometryService.to.importGeoJson(content, + projectId: projectId, sourceFileName: result.files.single.name); + Get.back(); // töltő-dialógus bezárása + if (res.skipped.isNotEmpty) { + Get.dialog(AlertDialog( + title: Text( + 'Import: ${res.imported} sikeres, ${res.skipped.length} kihagyva'), + content: SizedBox( + width: double.maxFinite, + child: ListView( + shrinkWrap: true, + children: res.skipped + .take(20) + .map( + (s) => Text('• $s', style: const TextStyle(fontSize: 12))) + .toList(), + ), + ), + actions: [ + TextButton(onPressed: Get.back, child: const Text('Rendben')), + ], + )); + } else { + Get.snackbar('Import kész', '${res.imported} parcella importálva.', + snackPosition: SnackPosition.BOTTOM); + } + } catch (e) { + Get.back(); + Get.snackbar('Import hiba', '$e', snackPosition: SnackPosition.BOTTOM); + } + } + + // ── Térképi megjelenítés ───────────────────────────────────────── + + Future viewOnMap({ + required String title, + required List targetProperties, + }) async { + if (targetProperties.isEmpty) return; + Get.dialog(const Center(child: CircularProgressIndicator()), + barrierDismissible: false); + try { + final rows = + await ParcelGeometryService.to.fetchGeometriesFor(targetProperties); + Get.back(); + Get.to(() => ParcelMapView(title: title, geometryRows: rows)); + } catch (e) { + Get.back(); + Get.snackbar('Hiba', 'Nem sikerült betölteni a térképi adatot: $e', + snackPosition: SnackPosition.BOTTOM); + } + } +} diff --git a/lib/pages/property_list/presentations/views/parcel_map_view.dart b/lib/pages/property_list/presentations/views/parcel_map_view.dart new file mode 100644 index 0000000..c9984a4 --- /dev/null +++ b/lib/pages/property_list/presentations/views/parcel_map_view.dart @@ -0,0 +1,195 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; + +/// Egy vagy több parcella kiemelt megjelenítése térképen — a lekérdezett +/// geometria-sorokból épül, a bbox alapján automatikusan a megfelelő +/// nézetre közelít/távolít. +/// +/// StatefulWidget itt indokolt kivétel: a kamera-illesztés (fitCamera) +/// csak a térkép első felépülése UTÁN hívható biztonságosan — ehhez egy +/// post-frame callback kell, nincs benne üzleti logika. +class ParcelMapView extends StatefulWidget { + final String title; + final List> geometryRows; + + const ParcelMapView({ + super.key, + required this.title, + required this.geometryRows, + }); + + @override + State createState() => _ParcelMapViewState(); +} + +class _ParcelMapViewState extends State { + final _mapController = MapController(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _fitBounds()); + } + + void _fitBounds() { + final bounds = _computeBounds(widget.geometryRows); + if (bounds == null) return; + _mapController.fitCamera( + CameraFit.bounds(bounds: bounds, padding: const EdgeInsets.all(48)), + ); + } + + LatLngBounds? _computeBounds(List> 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) { + final a = (row['bbox_min_lat'] as num).toDouble(); + final b = (row['bbox_max_lat'] as num).toDouble(); + final c = (row['bbox_min_lon'] as num).toDouble(); + final d = (row['bbox_max_lon'] as num).toDouble(); + if (a < minLat) minLat = a; + if (b > maxLat) maxLat = b; + if (c < minLon) minLon = c; + if (d > maxLon) maxLon = d; + } + return LatLngBounds(LatLng(minLat, minLon), LatLng(maxLat, maxLon)); + } + + List _parsePolygons(List> rows) { + final polygons = []; + for (final row in rows) { + final geom = row['geometry'] as Map; + final coords = geom['coordinates'] as List; + 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: Colors.deepOrange.withOpacity(0.35), + borderColor: Colors.deepOrange, + borderStrokeWidth: 2, + )); + } + } + return polygons; + } + + /// A parcella-szám feliratai, a poligon TÉNYLEGES súlypontjában (nem a + /// csúcspontok egyszerű átlagában — egy hosszú, elnyúlt teleknél az + /// utóbbi könnyen a parcellán kívülre esne). + List _buildLabelMarkers(List> rows) { + final markers = []; + for (final row in rows) { + // A nyers (nem normalizált, vezető nullákkal írt) formátumot + // mutatjuk, ha van — ez a "hivatalos" alak. Régebbi, e nélkül + // importált soroknál a normalizáltra esik vissza. + final parcelNumber = (row['parcel_number_raw'] as String?) ?? + (row['normalized_parcel_number'] as String? ?? ''); + final geom = row['geometry'] as Map; + final coords = geom['coordinates'] as List; + 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(); + final centroid = _polygonCentroid(points); + markers.add(Marker( + point: centroid, + width: 90, + height: 26, + child: IgnorePointer( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.85), + borderRadius: BorderRadius.circular(4), + border: Border.all(color: Colors.deepOrange.withOpacity(0.6)), + ), + child: Center( + child: Text( + parcelNumber, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Colors.black87), + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + )); + } + } + return markers; + } + + /// Sokszög-súlypont (terület szerint súlyozva, nem csúcspont-átlag) — + /// szabványos, "shoelace"-alapú képlet. + LatLng _polygonCentroid(List points) { + double area = 0, cx = 0, cy = 0; + final n = points.length; + for (var i = 0; i < n; i++) { + final p0 = points[i]; + final p1 = points[(i + 1) % n]; + final cross = p0.longitude * p1.latitude - p1.longitude * p0.latitude; + area += cross; + cx += (p0.longitude + p1.longitude) * cross; + cy += (p0.latitude + p1.latitude) * cross; + } + area *= 0.5; + if (area.abs() < 1e-12) { + // Degenerált eset (pl. egyenes vonalra eső pontok) — egyszerű átlag. + final avgLat = points.map((p) => p.latitude).reduce((a, b) => a + b) / n; + final avgLon = points.map((p) => p.longitude).reduce((a, b) => a + b) / n; + return LatLng(avgLat, avgLon); + } + cx /= (6 * area); + cy /= (6 * area); + return LatLng(cy, cx); + } + + @override + Widget build(BuildContext context) { + final polygons = _parsePolygons(widget.geometryRows); + final bounds = _computeBounds(widget.geometryRows); + + return Scaffold( + appBar: AppBar(title: Text(widget.title)), + body: widget.geometryRows.isEmpty + ? const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'Ehhez az ingatlanhoz/tulajdonoshoz nincs importált ' + 'geometria — ellenőrizd, importálva van-e a megfelelő ' + 'GeoJSON.', + textAlign: TextAlign.center, + ), + ), + ) + : FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: bounds?.center ?? const LatLng(47.1, 19.5), + initialZoom: 15, + maxZoom: 22, + ), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'hu.app_dev.terepi_seged', + maxNativeZoom: 18, + ), + PolygonLayer(polygons: polygons), + MarkerLayer(markers: _buildLabelMarkers(widget.geometryRows)) + ], + ), + ); + } +} diff --git a/lib/pages/property_list/presentations/views/property_list_view.dart b/lib/pages/property_list/presentations/views/property_list_view.dart index e67d5b6..f9b8f7d 100644 --- a/lib/pages/property_list/presentations/views/property_list_view.dart +++ b/lib/pages/property_list/presentations/views/property_list_view.dart @@ -1,12 +1,665 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:get/get.dart'; -import 'package:terepi_seged/pages/property_list/presentations/controllers/property_list_controller.dart'; +import 'package:terepi_seged/models/field_party.dart'; +import 'package:terepi_seged/models/field_property.dart'; +import 'package:terepi_seged/services/field_property_service.dart'; + +import '../controllers/property_list_controller.dart'; class PropertyListView extends GetView { - const PropertyListView({Key? key}) : super(key: key); + const PropertyListView({super.key}); @override Widget build(BuildContext context) { - return Container(); + return Scaffold( + appBar: AppBar( + title: const Text('Ingatlanok'), + actions: [ + IconButton( + icon: const Icon(Icons.upload_file_outlined), + tooltip: 'Geometria feltöltése', + onPressed: controller.importGeoJsonFile, + ) + ], + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 0), + child: Obx(() => SegmentedButton( + segments: const [ + ButtonSegment( + value: FieldViewMode.properties, + label: Text('Ingatlanok'), + icon: Icon(Icons.home_work_outlined)), + ButtonSegment( + value: FieldViewMode.parties, + label: Text('Tulajdonosok'), + icon: Icon(Icons.people_outline)), + ], + selected: {controller.viewMode.value}, + onSelectionChanged: (s) => + controller.viewMode.value = s.first, + )), + ), + Expanded( + child: Obx(() => + controller.viewMode.value == FieldViewMode.properties + ? const _PropertiesTab() + : const _PartiesTab()), + ), + ], + ), + ); + } +} + +// ═══════════════════════════════════════════════════════════════════ +// Ingatlanok fül +// ═══════════════════════════════════════════════════════════════════ + +class _PropertiesTab extends StatelessWidget { + const _PropertiesTab(); + + @override + Widget build(BuildContext context) { + final c = PropertyListController.to; + return Column( + children: [ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: TextField( + decoration: const InputDecoration( + prefixIcon: Icon(Icons.search), + hintText: 'Keresés (helyrajzi szám, tulajdonos, település)…', + border: OutlineInputBorder(), + isDense: true, + ), + onChanged: c.onSearchChanged, + ), + ), + const SizedBox(height: 8), + Obx(() { + final party = c.selectedParty.value; + if (party != null) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Align( + alignment: Alignment.centerLeft, + child: InputChip( + avatar: const Icon(Icons.person, size: 18), + label: Text('Tulajdonos: ${party.name}'), + onDeleted: c.clearPartySelection, + ), + ), + ); + } + return const _FilterRow(); + }), + const SizedBox(height: 4), + Obx(() { + if (c.properties.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + icon: const Icon(Icons.map_outlined, size: 18), + label: Text(c.selectedParty.value != null + ? 'Mind a térképen (${c.properties.length})' + : 'Találatok a térképen (${c.properties.length})'), + onPressed: () => c.viewOnMap( + title: + c.selectedParty.value?.name ?? 'Ingatlanok a térképen', + targetProperties: c.properties), + ), + ), + ); + }), + Expanded( + child: Obx(() { + if (c.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + if (c.properties.isEmpty) { + return const Center(child: Text('Nincs találat.')); + } + return ListView.builder( + itemCount: c.properties.length, + itemBuilder: (context, i) => + _PropertyCard(property: c.properties[i]), + ); + }), + ), + ], + ); + } +} + +class _FilterRow extends StatelessWidget { + const _FilterRow(); + + @override + Widget build(BuildContext context) { + final c = PropertyListController.to; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Expanded( + child: Obx(() => DropdownButtonFormField( + value: c.settlementFilter.value, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Település', isDense: true), + items: [ + const DropdownMenuItem(value: null, child: Text('Mind')), + ...c.settlements + .map((s) => DropdownMenuItem(value: s, child: Text(s))), + ], + onChanged: c.setSettlementFilter, + )), + ), + const SizedBox(width: 8), + Expanded( + child: Obx(() => DropdownButtonFormField( + value: c.mailingStatusFilter.value, + isExpanded: true, + decoration: const InputDecoration( + labelText: 'Levél-állapot', isDense: true), + items: const [ + DropdownMenuItem(value: null, child: Text('Mind')), + DropdownMenuItem( + value: 'unknown', child: Text('Ismeretlen')), + DropdownMenuItem( + value: 'not_required', child: Text('Nem szükséges')), + DropdownMenuItem(value: 'excluded', child: Text('Kizárva')), + DropdownMenuItem( + value: 'ready', child: Text('Előkészítve')), + DropdownMenuItem( + value: 'generated', child: Text('Legenerálva')), + DropdownMenuItem(value: 'sent', child: Text('Elküldve')), + DropdownMenuItem( + value: 'partial', child: Text('Részleges')), + ], + onChanged: c.setMailingStatusFilter, + )), + ), + ], + ), + ); + } +} + +class _PropertyCard extends StatelessWidget { + final FieldProperty property; + const _PropertyCard({required this.property}); + + @override + Widget build(BuildContext context) { + final color = _statusColor(property.mailingStatus); + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: ListTile( + title: Text('${property.settlement} — ${property.parcelNumber}', + style: const TextStyle(fontWeight: FontWeight.w600)), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (property.ownerNames.isNotEmpty) + Text(property.ownerNames, + maxLines: 2, overflow: TextOverflow.ellipsis), + if (property.totalAreaSquareMeters != null) + Text('${property.totalAreaSquareMeters} m²', + style: const TextStyle(fontSize: 12, color: Colors.grey)), + if (property.landUseSummary.isNotEmpty) + Text( + '${property.landUseSummary}', + style: const TextStyle(fontSize: 10, color: Colors.grey), + ) + ], + ), + isThreeLine: property.ownerNames.isNotEmpty && + property.totalAreaSquareMeters != null, + trailing: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: color.withOpacity(0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text(_statusLabel(property.mailingStatus), + style: TextStyle( + fontSize: 11, fontWeight: FontWeight.w700, color: color)), + ), + onTap: () => _showPropertyDetail(context, property), + ), + ); + } + + Color _statusColor(String status) { + switch (status) { + case 'sent': + return Colors.green; + case 'generated': + case 'ready': + return Colors.blue; + case 'partial': + return Colors.orange; + case 'excluded': + case 'not_required': + return Colors.grey; + default: + return Colors.red; + } + } + + String _statusLabel(String status) { + switch (status) { + case 'sent': + return 'Elküldve'; + case 'generated': + return 'Legenerálva'; + case 'ready': + return 'Előkészítve'; + case 'partial': + return 'Részleges'; + case 'excluded': + return 'Kizárva'; + case 'not_required': + return 'Nem szükséges'; + default: + return 'Ismeretlen'; + } + } +} + +void _showPropertyDetail(BuildContext context, FieldProperty property) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => _PropertyDetailSheet(property: property), + ); +} + +class _PropertyDetailSheet extends StatefulWidget { + final FieldProperty property; + const _PropertyDetailSheet({required this.property}); + + @override + State<_PropertyDetailSheet> createState() => _PropertyDetailSheetState(); +} + +class _PropertyDetailSheetState extends State<_PropertyDetailSheet> { + // StatefulWidget itt indokolt kivétel: egyszeri, aszinkron betöltés + // az ownership-bontáshoz, nincs benne üzleti logika — ugyanaz a minta, + // amit a QC-fotó előnézetnél is alkalmaztunk. + List>? _owners; + + @override + void initState() { + super.initState(); + FieldPropertyService.to + .propertyOwnershipDetail(widget.property.id) + .then((v) => setState(() => _owners = v)); + } + + @override + Widget build(BuildContext context) { + final p = widget.property; + return DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.3, + maxChildSize: 0.9, + expand: false, + builder: (context, scrollController) => ListView( + controller: scrollController, + padding: const EdgeInsets.all(16), + children: [ + Text('${p.settlement} — ${p.parcelNumber}', + style: + const TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Text('Norm. hrsz.: ${p.normalizedParcelNumber}', + style: const TextStyle(fontSize: 12, color: Colors.grey)), + const Divider(height: 24), + if (p.totalAreaSquareMeters != null) + _kv('Terület', '${p.totalAreaSquareMeters} m²'), + if (p.totalCadastralIncome != null) + _kv('Aranykorona', '${p.totalCadastralIncome}'), + if (p.landUseSummary.isNotEmpty) _kv('Művelési ág', p.landUseSummary), + _kv('Címzettek', '${p.recipientCount}'), + _kv('Legenerált levél', '${p.generatedLetterCount}'), + _kv('Elküldött levél', '${p.sentLetterCount}'), + const Divider(height: 24), + const Text('Tulajdonosok / kezelők', + style: TextStyle(fontWeight: FontWeight.w700)), + const SizedBox(height: 8), + if (_owners == null) + const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: CircularProgressIndicator())) + else if (_owners!.isEmpty) + const Text('Nincs rögzített tulajdonos.') + else + for (final o in _owners!) _OwnerRow(data: o), + const SizedBox(height: 16), + OutlinedButton.icon( + icon: const Icon(Icons.map_outlined), + label: const Text('Megtekintés a térképen'), + onPressed: () { + Navigator.of(context).pop(); + PropertyListController.to.viewOnMap( + title: + '${widget.property.settlement} - ${widget.property.parcelNumber}', + targetProperties: [widget.property]); + }, + ) + ], + ), + ); + } + + Widget _kv(String k, String v) => Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(k, style: const TextStyle(color: Colors.grey)), + Text(v, style: const TextStyle(fontWeight: FontWeight.w600)), + ], + ), + ); +} + +class _OwnerRow extends StatelessWidget { + final Map data; + const _OwnerRow({required this.data}); + + @override + Widget build(BuildContext context) { + final party = data['terepi_seged_field_parties'] as Map?; + final name = party?['name'] as String? ?? '(ismeretlen)'; + final shareText = data['share_original_text'] as String?; + final role = data['role'] as String?; + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.person_outline), + title: Text(name), + subtitle: Text([ + if (role != null) role, + if (shareText != null) shareText, + ].join(' · ')), + ); + } +} + +// ═══════════════════════════════════════════════════════════════════ +// Tulajdonosok fül +// ═══════════════════════════════════════════════════════════════════ + +class _PartiesTab extends StatefulWidget { + const _PartiesTab(); + + @override + State<_PartiesTab> createState() => _PartiesTabState(); +} + +class _PartiesTabState extends State<_PartiesTab> { + final _scrollController = ScrollController(); + static const _itemHeight = 72.0; + + // A húzás közben aktív betű + a buborék függőleges pozíciója — külön + // ValueNotifier, hogy NE váltson ki teljes Obx-újraépítést minden egyes + // ujjmozdulatnál, csak a buborék widgetjét frissítse. + final _activeLetter = ValueNotifier(null); + final _bubbleY = ValueNotifier(0); + + @override + void dispose() { + _scrollController.dispose(); + _activeLetter.dispose(); + _bubbleY.dispose(); + super.dispose(); + } + + String _bucketLetter(String name) { + if (name.isEmpty) return '#'; + final ch = name[0].toUpperCase(); + const map = { + 'Á': 'A', + 'É': 'E', + 'Í': 'I', + 'Ó': 'O', + 'Ö': 'O', + 'Ő': 'O', + 'Ú': 'U', + 'Ü': 'U', + 'Ű': 'U', + }; + return map[ch] ?? ch; + } + + void _jumpToLetter(String letter, List parties) { + final index = parties.indexWhere((p) => _bucketLetter(p.name) == letter); + if (index < 0 || !_scrollController.hasClients) return; + final maxScroll = _scrollController.position.maxScrollExtent; + final target = (index * _itemHeight).clamp(0.0, maxScroll); + // jumpTo, NEM animateTo — húzás közben a listának AZONNAL, ujjal + // együtt kell mozognia, nem egy lassabb animációt kergetve. + _scrollController.jumpTo(target); + } + + @override + Widget build(BuildContext context) { + final c = PropertyListController.to; + return Column( + children: [ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: TextField( + controller: c.partySearchController, + decoration: InputDecoration( + prefixIcon: const Icon(Icons.search), + suffixIcon: Obx(() => c.partySearchText.value.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: c.clearPartySearch, + ) + : const SizedBox.shrink()), + hintText: 'Tulajdonos neve…', + border: const OutlineInputBorder(), + isDense: true, + ), + onChanged: c.onPartySearchChanged, + ), + ), + const SizedBox(height: 8), + Expanded( + child: Obx(() { + if (c.isLoadingParties.value) { + return const Center(child: CircularProgressIndicator()); + } + if (c.parties.isEmpty) { + return const Center(child: Text('Nincs tulajdonos.')); + } + final parties = c.parties; + final letters = {}; + for (final p in parties) { + letters.add(_bucketLetter(p.name)); + } + final sortedLetters = letters.toList()..sort(); + + return Stack( + children: [ + ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.only(right: 28), + itemCount: parties.length, + itemBuilder: (context, i) { + final party = parties[i]; + return SizedBox( + height: _itemHeight, + child: ListTile( + leading: const Icon(Icons.person_outline), + title: Text(party.name, + maxLines: 1, overflow: TextOverflow.ellipsis), + subtitle: Text('${party.propertyCount} ingatlan' + '${party.settlement != null ? ' · ${party.settlement}' : ''}'), + trailing: const Icon(Icons.chevron_right), + onTap: () => c.selectParty(party), + ), + ); + }, + ), + if (sortedLetters.length > 3) ...[ + Positioned( + right: 0, + top: 0, + bottom: 0, + child: _AlphabetIndexBar( + letters: sortedLetters, + onLetterChanged: (letter, dy) { + _activeLetter.value = letter; + _bubbleY.value = dy; + _jumpToLetter(letter, parties); + }, + onDragEnd: () => _activeLetter.value = null, + ), + ), + ValueListenableBuilder( + valueListenable: _activeLetter, + builder: (context, letter, _) { + if (letter == null) return const SizedBox.shrink(); + return ValueListenableBuilder( + valueListenable: _bubbleY, + builder: (context, y, __) => Positioned( + right: 56, + top: (y - 32).clamp( + 0.0, MediaQuery.of(context).size.height - 64), + child: _LetterBubble(letter: letter), + ), + ); + }, + ), + ], + ], + ); + }), + ), + ], + ); + } +} + +/// Az egész sáv EGYETLEN, folyamatos húzás-érzékelő terület — nem +/// betűnkénti, apró koppintás-célpontok. Az ujj bárhol a sávon +/// (fel-le mozgatva is) azonnal a megfelelő betűre ugrik, pontosan +/// úgy, ahogy a natív Kontaktok-app ABC-sávja működik. +class _AlphabetIndexBar extends StatefulWidget { + final List letters; + final void Function(String letter, double dy) onLetterChanged; + final VoidCallback onDragEnd; + + const _AlphabetIndexBar({ + required this.letters, + required this.onLetterChanged, + required this.onDragEnd, + }); + + @override + State<_AlphabetIndexBar> createState() => _AlphabetIndexBarState(); +} + +class _AlphabetIndexBarState extends State<_AlphabetIndexBar> { + final _key = GlobalKey(); + String? _lastLetter; + + void _handlePosition(Offset globalPosition) { + final box = _key.currentContext?.findRenderObject() as RenderBox?; + if (box == null) return; + final local = box.globalToLocal(globalPosition); + final height = box.size.height; + if (height <= 0) return; + + final ratio = (local.dy / height).clamp(0.0, 0.999); + final index = (ratio * widget.letters.length) + .floor() + .clamp(0, widget.letters.length - 1); + final letter = widget.letters[index]; + + widget.onLetterChanged(letter, local.dy); + + // Csak akkor rezegjen, amikor TÉNYLEGESEN új betűre lép — ne + // minden egyes pixelnyi mozdulatnál. + if (letter != _lastLetter) { + _lastLetter = letter; + HapticFeedback.selectionClick(); + } + } + + void _handleEnd() { + _lastLetter = null; + widget.onDragEnd(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onVerticalDragStart: (d) => _handlePosition(d.globalPosition), + onVerticalDragUpdate: (d) => _handlePosition(d.globalPosition), + onVerticalDragEnd: (_) => _handleEnd(), + onTapDown: (d) => _handlePosition(d.globalPosition), + onTapUp: (_) => _handleEnd(), + child: Container( + key: _key, + width: 28, + color: Colors.transparent, + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + for (final letter in widget.letters) + Text(letter, + style: const TextStyle( + fontSize: 11, fontWeight: FontWeight.w600)), + ], + ), + ), + ); + } +} + +/// A húzás közben megjelenő, nagy, kiemelt betű-buborék — a Kontaktok-app +/// mintájára, hogy az ujj alatt is jól látszódjon, épp hol tartasz. +class _LetterBubble extends StatelessWidget { + final String letter; + const _LetterBubble({required this.letter}); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: Container( + width: 64, + height: 64, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + shape: BoxShape.circle, + boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 8)], + ), + alignment: Alignment.center, + child: Text( + letter, + style: const TextStyle( + fontSize: 28, fontWeight: FontWeight.bold, color: Colors.white), + ), + ), + ); } } diff --git a/lib/services/device_identity_service.dart b/lib/services/device_identity_service.dart index 9591e4e..636a187 100644 --- a/lib/services/device_identity_service.dart +++ b/lib/services/device_identity_service.dart @@ -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) ──────────────────────────────────── diff --git a/lib/services/field_property_service.dart b/lib/services/field_property_service.dart new file mode 100644 index 0000000..20ebd35 --- /dev/null +++ b/lib/services/field_property_service.dart @@ -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> 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> 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> 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>> 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>.from(rows); + } +} diff --git a/lib/services/parcel_geometry_service.dart b/lib/services/parcel_geometry_service.dart new file mode 100644 index 0000000..acc2137 --- /dev/null +++ b/lib/services/parcel_geometry_service.dart @@ -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 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 props, List 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 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 + : []; + + var imported = 0; + final skipped = []; + + 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() ?? {}; + 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>> fetchGeometriesFor( + List 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 = >{}; + for (final p in properties) { + bySettlement + .putIfAbsent(p.settlement, () => {}) + .add(p.normalizedParcelNumber); + } + + final result = >[]; + 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>.from(rows)); + } + return result; + } + + // ═════════════════════════════════════════════════════════════════ + // flutter_map segédek + // ═════════════════════════════════════════════════════════════════ + + List parsePolygons(List> rows, + {Color color = Colors.deepOrange}) { + final polygons = []; + 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> 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, + }); +} diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart index e9d706b..3d39f2d 100644 --- a/lib/widgets/app_drawer.dart +++ b/lib/widgets/app_drawer.dart @@ -122,6 +122,22 @@ class AppDrawer extends StatelessWidget { }, ); }), + Obx(() { + final signedIn = AuthService.to.isSignedIn; + // final projectId = ProjectService.to.activeProject.value?.uuid; + // final allowed = !Get.isRegistered() || + // PermissionService.to.canContacts(projectId: projectId); + final allowed = true; + if (!signedIn || !allowed) return const SizedBox.shrink(); + return ListTile( + leading: const Icon(Icons.house_outlined), + title: const Text('Ingatlanok'), + onTap: () { + Get.back(); + Get.toNamed(Routes.PROPERTYLIST); + }, + ); + }), Obx(() => ListTile( leading: TsSyncService.to.isSyncing.value ? const SizedBox(