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,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/services.dart';
import 'package:get/get.dart';
import 'package:terepi_seged/pages/property_list/presentations/controllers/property_list_controller.dart';
import 'package:terepi_seged/models/field_party.dart';
import 'package:terepi_seged/models/field_property.dart';
import 'package:terepi_seged/services/field_property_service.dart';
import '../controllers/property_list_controller.dart';
class PropertyListView extends GetView<PropertyListController> {
const PropertyListView({Key? key}) : super(key: key);
const PropertyListView({super.key});
@override
Widget build(BuildContext context) {
return Container();
return Scaffold(
appBar: AppBar(
title: const Text('Ingatlanok'),
actions: [
IconButton(
icon: const Icon(Icons.upload_file_outlined),
tooltip: 'Geometria feltöltése',
onPressed: controller.importGeoJsonFile,
)
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
child: Obx(() => SegmentedButton<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),
),
),
);
}
}