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

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