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
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 6s
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
org.gradle.jvmargs=-Xmx1536M
|
org.gradle.jvmargs=-Xmx1536M
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=false
|
||||||
# This builtInKotlin flag was added automatically by Flutter migrator
|
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||||
android.builtInKotlin=false
|
android.builtInKotlin=false
|
||||||
# This newDsl flag was added automatically by Flutter migrator
|
# This newDsl flag was added automatically by Flutter migrator
|
||||||
|
|||||||
@@ -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/contact_service.dart';
|
||||||
import 'package:terepi_seged/services/coord_converter_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/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/firebase_logger.dart';
|
||||||
import 'package:terepi_seged/services/gnss/gnss_device_service.dart';
|
import 'package:terepi_seged/services/gnss/gnss_device_service.dart';
|
||||||
import 'package:terepi_seged/services/gnss/gnss_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_audio_service.dart';
|
||||||
import 'package:terepi_seged/services/note_photo_service.dart';
|
import 'package:terepi_seged/services/note_photo_service.dart';
|
||||||
import 'package:terepi_seged/services/ntrip_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/permission_service.dart';
|
||||||
import 'package:terepi_seged/services/project_service.dart';
|
import 'package:terepi_seged/services/project_service.dart';
|
||||||
import 'package:terepi_seged/services/stakeout_service.dart';
|
import 'package:terepi_seged/services/stakeout_service.dart';
|
||||||
@@ -113,6 +115,8 @@ Future<void> main() async {
|
|||||||
Get.put(PermissionService());
|
Get.put(PermissionService());
|
||||||
Get.put(ContactService());
|
Get.put(ContactService());
|
||||||
Get.put(VehicleIdentityService());
|
Get.put(VehicleIdentityService());
|
||||||
|
Get.put(FieldPropertyService());
|
||||||
|
Get.put(ParcelGeometryService());
|
||||||
|
|
||||||
runApp(const MyApp());
|
runApp(const MyApp());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<String, dynamic> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<String, dynamic> 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),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -90,6 +90,17 @@ class Project {
|
|||||||
createdAt: DateTime.parse(m['created_at'] as String),
|
createdAt: DateTime.parse(m['created_at'] as String),
|
||||||
updatedAt: DateTime.parse(m['updated_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(
|
bool _readBool(
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ class ContactsView extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Jogosultság-kapu — ha nincs joga, be sem töltjük a listát.
|
// Jogosultság-kapu — ha nincs joga, be sem töltjük a listát.
|
||||||
if (Get.isRegistered<PermissionService>() &&
|
if (Get.isRegistered<PermissionService>() &&
|
||||||
!PermissionService.to.canContacts) {
|
!PermissionService.to.canContacts(
|
||||||
|
projectId: ProjectService.to.activeProject.value?.uuid)) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('Kapcsolatok')),
|
appBar: AppBar(title: const Text('Kapcsolatok')),
|
||||||
body: const _NoAccess(),
|
body: const _NoAccess(),
|
||||||
|
|||||||
@@ -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<String>();
|
||||||
|
final mailingStatusFilter = Rxn<String>();
|
||||||
|
final isLoading = false.obs;
|
||||||
|
final properties = <FieldProperty>[].obs;
|
||||||
|
final settlements = <String>[].obs;
|
||||||
|
|
||||||
|
/// Ha nem null, a lista egy KIVÁLASZTOTT tulajdonos ingatlanjait
|
||||||
|
/// mutatja, nem az általános keresést/szűrést.
|
||||||
|
final selectedParty = Rxn<FieldParty>();
|
||||||
|
|
||||||
|
// ── Tulajdonosok fül ─────────────────────────────────────────────
|
||||||
|
final partySearchText = ''.obs;
|
||||||
|
final isLoadingParties = false.obs;
|
||||||
|
final parties = <FieldParty>[].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<void> 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<void> 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<void> 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<void> 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<void> viewOnMap({
|
||||||
|
required String title,
|
||||||
|
required List<FieldProperty> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<Map<String, dynamic>> geometryRows;
|
||||||
|
|
||||||
|
const ParcelMapView({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.geometryRows,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ParcelMapView> createState() => _ParcelMapViewState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ParcelMapViewState extends State<ParcelMapView> {
|
||||||
|
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<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) {
|
||||||
|
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<Polygon> _parsePolygons(List<Map<String, dynamic>> rows) {
|
||||||
|
final polygons = <Polygon>[];
|
||||||
|
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<Marker> _buildLabelMarkers(List<Map<String, dynamic>> rows) {
|
||||||
|
final markers = <Marker>[];
|
||||||
|
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<LatLng> 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))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,665 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:get/get.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<PropertyListController> {
|
class PropertyListView extends GetView<PropertyListController> {
|
||||||
const PropertyListView({Key? key}) : super(key: key);
|
const PropertyListView({super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
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<FieldViewMode>(
|
||||||
|
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<String?>(
|
||||||
|
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<String?>(
|
||||||
|
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<Map<String, dynamic>>? _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<String, dynamic> 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<String?>(null);
|
||||||
|
final _bubbleY = ValueNotifier<double>(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<FieldParty> 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 = <String>{};
|
||||||
|
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<String?>(
|
||||||
|
valueListenable: _activeLetter,
|
||||||
|
builder: (context, letter, _) {
|
||||||
|
if (letter == null) return const SizedBox.shrink();
|
||||||
|
return ValueListenableBuilder<double>(
|
||||||
|
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<String> 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,25 +190,22 @@ class DeviceIdentityService extends GetxService {
|
|||||||
final user = Supabase.instance.client.auth.currentUser;
|
final user = Supabase.instance.client.auth.currentUser;
|
||||||
if (user == null || deviceId.isEmpty) return;
|
if (user == null || deviceId.isEmpty) return;
|
||||||
|
|
||||||
await Supabase.instance.client.from('terepi_seged_devices').upsert({
|
try {
|
||||||
'id': appInstanceId,
|
await Supabase.instance.client.from('terepi_seged_devices').upsert({
|
||||||
'user_id': user.id,
|
'id': appInstanceId,
|
||||||
'name': info.systemDeviceName,
|
'user_id': user.id,
|
||||||
'device_id': deviceId,
|
'name': info.systemDeviceName,
|
||||||
'platform': info.platform,
|
'device_id': deviceId,
|
||||||
'model': model,
|
'platform': info.platform,
|
||||||
'os_version': info.osVersion,
|
'model': model,
|
||||||
'app_version': appInfo,
|
'os_version': info.osVersion,
|
||||||
'last_seen_at': DateTime.now().toUtc().toIso8601String()
|
'app_version': appInfo,
|
||||||
});
|
'last_seen_at': DateTime.now().toUtc().toIso8601String()
|
||||||
|
});
|
||||||
//
|
} catch (e) {
|
||||||
// // await Supabase.instance.client
|
AppLogger.e('DeviceIdentityService.registerDevice',
|
||||||
// .from('devices')
|
'Eszköz-regisztráció hiba (user=${user.id} - device=$deviceId): $e');
|
||||||
// .upsert(
|
}
|
||||||
// info.toRegistrationMap(label: deviceLabel.value),
|
|
||||||
// onConflict: 'device_id',
|
|
||||||
// );
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Gyors elérők (kényelemért) ────────────────────────────────────
|
// ── Gyors elérők (kényelemért) ────────────────────────────────────
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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>() ||
|
||||||
|
// 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(
|
Obx(() => ListTile(
|
||||||
leading: TsSyncService.to.isSyncing.value
|
leading: TsSyncService.to.isSyncing.value
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
|
|||||||
Reference in New Issue
Block a user