From f1bb0191d57da8842793cf5352ae9c00a965c5b4 Mon Sep 17 00:00:00 2001 From: "torok.istvan" Date: Sun, 4 Oct 2026 22:24:31 +0200 Subject: [PATCH] =?UTF-8?q?Navig=C3=A1ci=C3=B3s=20k=C3=A9perny=C5=91n=20?= =?UTF-8?q?=C3=BAtvonal=20megjelen=C3=ADt=C3=A9se=20supabase=20t=C3=A1bl?= =?UTF-8?q?=C3=A1b=C3=B3l=20=C3=A9s=20navig=C3=A1ci=C3=B3s=20k=C3=A9perny?= =?UTF-8?q?=C5=91vel=20t=C3=B6rt=C3=A9n=C5=91=20ind=C3=ADt=C3=A1s=20be?= =?UTF-8?q?=C3=A1ll=C3=ADt=C3=A1sok=20alapj=C3=A1n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/main.dart | 3 + lib/models/vibro_nav_route.dart | 199 +++++++++++ .../controllers/start_page_controller.dart | 10 + .../controllers/vibro_nav_controller.dart | 34 +- .../vibro_nav_routes_controller.dart | 262 ++++++++++++++ .../views/vibro_nav_routes_view.dart | 325 ++++++++++++++++++ .../presentation/views/vibro_nav_view.dart | 35 ++ lib/services/nav_autostart_service.dart | 101 ++++++ lib/services/vibro_nav_route_service.dart | 253 ++++++++++++++ 9 files changed, 1221 insertions(+), 1 deletion(-) create mode 100644 lib/models/vibro_nav_route.dart create mode 100644 lib/pages/vibro_nav/presentation/controllers/vibro_nav_routes_controller.dart create mode 100644 lib/pages/vibro_nav/presentation/views/vibro_nav_routes_view.dart create mode 100644 lib/services/nav_autostart_service.dart create mode 100644 lib/services/vibro_nav_route_service.dart diff --git a/lib/main.dart b/lib/main.dart index e60f2ad..7630a23 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import 'package:get/get.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:terepi_seged/pages/tracking/presentation/controllers/tracking_controller.dart'; import 'package:terepi_seged/pages/update_required/presentation/views/update_required_view.dart'; +import 'package:terepi_seged/pages/vibro_nav/presentation/controllers/vibro_nav_controller.dart'; import 'package:terepi_seged/routes/app_pages.dart'; import 'package:terepi_seged/services/app_database.dart'; import 'package:terepi_seged/services/app_logger.dart'; @@ -36,6 +37,7 @@ import 'package:terepi_seged/services/track_sync_service.dart'; import 'package:terepi_seged/services/ts_sync_service.dart'; import 'package:terepi_seged/services/vechicle_identity_service.dart'; import 'package:terepi_seged/services/version_gate_service.dart'; +import 'package:terepi_seged/services/vibro_nav_route_service.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -121,6 +123,7 @@ Future main() async { Get.put(ParcelGeometryService()); Get.put(MeasuredDataService()); Get.put(MeasureSourcePointService()); + Get.put(VibroNavRouteService()); runApp(const MyApp()); } diff --git a/lib/models/vibro_nav_route.dart b/lib/models/vibro_nav_route.dart new file mode 100644 index 0000000..84b6284 --- /dev/null +++ b/lib/models/vibro_nav_route.dart @@ -0,0 +1,199 @@ +import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:terepi_seged/services/coord_converter_service.dart'; +import 'package:get/get.dart'; + +/// Egy navigációs útvonal (kerülő, megközelítés…) a +/// `terepi_seged_vibro_nav_routes` táblából. +/// +/// A `geometry` az EREDETI GeoJSON (wgs84 vagy eov), a `lines` pedig a +/// BETÖLTÉSKOR egyszer, WGS84-re átváltott, kirajzolásra kész vonalak — +/// a térkép így nem számol újra GNSS-frissítéskor. +class VibroNavRoute { + final String id; + final String projectId; + final String name; + final Map geometry; + final String crs; // 'auto' | 'wgs84' | 'eov' + final DateTime? validDate; + final String? vehicle; + final String routeKind; // 'detour' | 'access' | 'restricted' + final String? color; + final bool isActive; + final List> lines; + + const VibroNavRoute({ + required this.id, + required this.projectId, + required this.name, + required this.geometry, + required this.crs, + required this.validDate, + required this.vehicle, + required this.routeKind, + required this.color, + required this.isActive, + required this.lines, + }); + + factory VibroNavRoute.fromMap(Map m) { + final geometry = Map.from(m['geometry'] as Map); + final crs = (m['crs'] as String?) ?? 'auto'; + final dateStr = m['valid_date'] as String?; + return VibroNavRoute( + id: m['id'] as String, + projectId: m['project_id'] as String, + name: (m['name'] as String?) ?? '', + geometry: geometry, + crs: crs, + validDate: dateStr == null ? null : DateTime.parse(dateStr), + vehicle: (m['vehicle'] as String?)?.trim().isEmpty ?? true + ? null + : (m['vehicle'] as String).trim(), + routeKind: (m['route_kind'] as String?) ?? 'detour', + color: m['color'] as String?, + isActive: (m['is_active'] as bool?) ?? true, + lines: toWgsLines(geometry, crs), + ); + } + + int get pointCount => lines.fold(0, (s, l) => s + l.length); + + String get dateLabel => validDate == null + ? 'minden nap' + : '${validDate!.year}-${validDate!.month.toString().padLeft(2, '0')}-' + '${validDate!.day.toString().padLeft(2, '0')}'; + + String get vehicleLabel => vehicle ?? 'minden jármű'; + + // ═════════════════════════════════════════════════════════════════ + // Fajták / színek + // ═════════════════════════════════════════════════════════════════ + + static const kinds = { + 'detour': 'Kerülő', + 'access': 'Megközelítés', + 'restricted': 'Tiltott / veszélyes', + }; + + static Color colorFor(String kind, String? hex) { + final custom = _parseHex(hex); + if (custom != null) return custom; + switch (kind) { + case 'access': + return const Color(0xFF2979FF); + case 'restricted': + return const Color(0xFFD50000); + case 'detour': + default: + return const Color(0xFFFFAB00); + } + } + + static Color? _parseHex(String? hex) { + if (hex == null) return null; + var h = hex.trim().replaceFirst('#', ''); + if (h.length == 6) h = 'FF$h'; + if (h.length != 8) return null; + final v = int.tryParse(h, radix: 16); + return v == null ? null : Color(v); + } + + // ═════════════════════════════════════════════════════════════════ + // Geometria → WGS84 vonalak + // ═════════════════════════════════════════════════════════════════ + + /// Megállapítja a koordinátarendszert az ELSŐ koordináta alapján + /// (egy geometrián belül egységes vetületet feltételezünk). + /// WGS84: |x| ≤ 180 és |y| ≤ 90. EOV: több tízezres–százezres értékek. + static String detectCrs(Map geometry) { + final first = _firstCoordinate(geometry); + if (first == null) return 'wgs84'; + return (first[0].abs() <= 180 && first[1].abs() <= 90) ? 'wgs84' : 'eov'; + } + + static List? _firstCoordinate(Map geometry) { + final lines = _rawLines(geometry); + for (final l in lines) { + if (l.isNotEmpty) return l.first; + } + return null; + } + + /// A GeoJSON geometria nyers vonalai ([x, y] párok listájának listája). + static List>> _rawLines(Map geometry) { + final type = geometry['type']; + final coords = geometry['coordinates']; + if (coords is! List) return const []; + + List> parseLine(dynamic raw) { + final out = >[]; + if (raw is! List) return out; + for (final c in raw) { + if (c is List && c.length >= 2 && c[0] is num && c[1] is num) { + out.add([(c[0] as num).toDouble(), (c[1] as num).toDouble()]); + } + } + return out; + } + + if (type == 'LineString') return [parseLine(coords)]; + if (type == 'MultiLineString') return coords.map(parseLine).toList(); + return const []; + } + + /// WGS84 [LatLng] vonalak. A nem véges vagy érvénytelen pontokat + /// eldobja (a LatLng NaN összeomlasztja a térképet), a 2 pontnál + /// rövidebb vonalakat kihagyja. + static List> toWgsLines( + Map geometry, String crs) { + final raw = _rawLines(geometry); + if (raw.isEmpty) return const []; + + final effective = crs == 'auto' ? detectCrs(geometry) : crs; + final out = >[]; + + for (final line in raw) { + final pts = []; + for (final c in line) { + final ll = effective == 'eov' ? _eovToLatLng(c[0], c[1]) : _wgs(c); + if (ll != null) pts.add(ll); + } + if (pts.length >= 2) out.add(pts); + } + return out; + } + + static LatLng? _wgs(List c) { + var lon = c[0]; + var lat = c[1]; + // Gyakori hiba: felcserélt sorrend [szélesség, hosszúság]. Magyarország + // területén a szélesség 45–49, a hosszúság 16–23, ezért egyértelmű. + if (lon > 44 && lon < 50 && lat > 15 && lat < 24) { + final t = lon; + lon = lat; + lat = t; + } + if (!lon.isFinite || !lat.isFinite) return null; + if (lon.abs() > 180 || lat.abs() > 90) return null; + return LatLng(lat, lon); + } + + static LatLng? _eovToLatLng(double a, double b) { + if (!Get.isRegistered()) return null; + // Szabvány: [EOV Y (kelet ~400k–950k), EOV X (észak ~45k–350k)]. + // Ha felcserélve érkezett, javítjuk. + var y = a; + var x = b; + if (y < 400000 && x > 400000) { + final t = y; + y = x; + x = t; + } + try { + final w = CoordConverterService.to.eovToWgsPoint(y, x); + if (w.x.isFinite && w.y.isFinite) return LatLng(w.y, w.x); + } catch (_) {} + return null; + } +} diff --git a/lib/pages/start/presentation/controllers/start_page_controller.dart b/lib/pages/start/presentation/controllers/start_page_controller.dart index 0855b35..f0c7a87 100644 --- a/lib/pages/start/presentation/controllers/start_page_controller.dart +++ b/lib/pages/start/presentation/controllers/start_page_controller.dart @@ -6,6 +6,7 @@ import 'package:intl/intl.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:terepi_seged/routes/app_pages.dart'; +import 'package:terepi_seged/services/nav_autostart_service.dart'; class StartPageController extends GetxController { late PermissionStatus bluetoothStatus; @@ -97,8 +98,17 @@ class StartPageController extends GetxController { } void _departStartPage() async { + final autoStartNav = NavAutoStartService.shouldStartNavigation(); + await Future.delayed(const Duration(seconds: 5)); + final startNav = await autoStartNav; + log('🚀 NavAutoStart döntés: startNav=$startNav'); //Get.offAllNamed(Routes.HOME); Get.offAllNamed(Routes.SHELL); + + if (startNav) { + await Future.delayed(const Duration(milliseconds: 150)); + Get.toNamed(Routes.VIBRONAV); + } } } diff --git a/lib/pages/vibro_nav/presentation/controllers/vibro_nav_controller.dart b/lib/pages/vibro_nav/presentation/controllers/vibro_nav_controller.dart index 81d032d..18b83b7 100644 --- a/lib/pages/vibro_nav/presentation/controllers/vibro_nav_controller.dart +++ b/lib/pages/vibro_nav/presentation/controllers/vibro_nav_controller.dart @@ -8,6 +8,7 @@ import 'package:get/get.dart'; import 'package:latlong2/latlong.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:terepi_seged/models/measure_source_point.dart'; +import 'package:terepi_seged/models/vibro_nav_route.dart'; import 'package:terepi_seged/services/app_logger.dart'; import 'package:terepi_seged/services/coord_converter_service.dart'; import 'package:terepi_seged/services/gnss/gnss_connection.dart'; @@ -16,6 +17,7 @@ import 'package:terepi_seged/services/gnss/phone_gps_connection.dart'; import 'package:terepi_seged/services/measure_source_point_service.dart'; import 'package:terepi_seged/services/project_service.dart'; import 'package:terepi_seged/services/vechicle_identity_service.dart'; +import 'package:terepi_seged/services/vibro_nav_route_service.dart'; /// Forráspont + egyszer kiszámolt WGS84 pozíció. A térkép ebből rajzol, /// így GNSS-frissítéskor nincs újraszámolás. @@ -66,6 +68,9 @@ class VibroNavController extends GetxController { final Map _geoCache = {}; final isLoadingSourcePoints = false.obs; + final routes = [].obs; + StreamSubscription>? _routesSub; + /// Ha false, a tartalék (plan_state = 0) pontok nem jelennek meg a /// térképen — csak a jelgerjesztés helyei (plan_state = 1) látszanak. final showReservePoints = false.obs; @@ -93,7 +98,11 @@ class VibroNavController extends GetxController { super.onInit(); watchSourcePoints(); - ever(ProjectService.to.activeProject, (_) => watchSourcePoints()); + watchRoutes(); + ever(ProjectService.to.activeProject, (_) { + watchSourcePoints(); + watchRoutes(); + }); if (Get.isRegistered()) { _gnssUpdateSub = @@ -130,6 +139,7 @@ class VibroNavController extends GetxController { void onClose() { _gnssUpdateSub?.cancel(); _sourcePointsSub?.cancel(); + _routesSub?.cancel(); _externalUploadTimer?.cancel(); _internalUploadTimer?.cancel(); _internalPositionSub?.cancel(); @@ -357,4 +367,26 @@ class VibroNavController extends GetxController { List get visibleGeoSourcePoints => showReservePoints.value ? geoSourcePoints : geoSourcePoints.where((g) => g.point.isShotLocation).toList(); + + void watchRoutes() { + _routesSub?.cancel(); + final projectId = ProjectService.to.activeProject.value?.uuid; + if (projectId == null) { + routes.clear(); + return; + } + _routesSub = VibroNavRouteService.to.watchForToday(projectId).listen( + (list) => routes.value = list, + onError: (e) => + AppLogger.e('VibroNavController', 'Útvonal-stream hiba: $e'), + ); + } + + /// A térkép ezt rajzolja: a mai napra érvényes útvonalak közül azok, + /// amelyek minden járműre szólnak, vagy erre a járműre. A járműváltásra + /// azonnal reagál. + List get visibleRoutes { + final v = VehicleIdentityService.to.selectedVehicle.value; + return routes.where((r) => r.vehicle == null || r.vehicle == v).toList(); + } } diff --git a/lib/pages/vibro_nav/presentation/controllers/vibro_nav_routes_controller.dart b/lib/pages/vibro_nav/presentation/controllers/vibro_nav_routes_controller.dart new file mode 100644 index 0000000..923c3c0 --- /dev/null +++ b/lib/pages/vibro_nav/presentation/controllers/vibro_nav_routes_controller.dart @@ -0,0 +1,262 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:terepi_seged/models/vibro_nav_route.dart'; +import 'package:terepi_seged/services/app_logger.dart'; +import 'package:terepi_seged/services/project_service.dart'; +import 'package:terepi_seged/services/vibro_nav_route_service.dart'; + +/// Az útvonalak kezelő (admin) oldalának controllere: lista, GeoJSON-import, +/// szerkesztés (név, dátum, jármű, fajta, aktív), törlés. +class VibroNavRoutesController extends GetxController { + static VibroNavRoutesController get to => Get.find(); + + final routes = [].obs; + final isLoading = true.obs; + final isBusy = false.obs; + + // Közös űrlap-állapot (import ÉS szerkesztés). + final nameCtrl = TextEditingController(); + final formDate = Rxn(); // null = minden nap + final formVehicle = Rxn(); // null = minden jármű + final formKind = 'detour'.obs; + final formActive = true.obs; + + VibroNavRoute? _editing; + List _drafts = []; + + bool get isEditing => _editing != null; + VibroNavRoute? get editingRoute => _editing; + int get draftCount => _drafts.length; + + StreamSubscription>? _sub; + Worker? _projectWorker; + + String? get projectId => ProjectService.to.activeProject.value?.uuid; + + @override + void onInit() { + super.onInit(); + _subscribe(); + _projectWorker = ever(ProjectService.to.activeProject, (_) => _subscribe()); + } + + @override + void onClose() { + _sub?.cancel(); + _projectWorker?.dispose(); + nameCtrl.dispose(); + super.onClose(); + } + + void _subscribe() { + _sub?.cancel(); + final pid = projectId; + if (pid == null) { + routes.clear(); + isLoading.value = false; + return; + } + isLoading.value = true; + _sub = VibroNavRouteService.to.watchAll(pid).listen( + (list) { + routes.value = list; + isLoading.value = false; + }, + onError: (e) { + isLoading.value = false; + AppLogger.e('VibroNavRoutesController', 'Útvonal-lista hiba: $e'); + }, + ); + } + + // ═════════════════════════════════════════════════════════════════ + // Űrlap-segédek + // ═════════════════════════════════════════════════════════════════ + + static DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day); + + static bool sameDay(DateTime? a, DateTime? b) => + a != null && b != null && _dateOnly(a) == _dateOnly(b); + + void setDateNone() => formDate.value = null; + + void setDateOffset(int days) => + formDate.value = _dateOnly(DateTime.now().add(Duration(days: days))); + + Future pickDate(BuildContext context) async { + final now = DateTime.now(); + final picked = await showDatePicker( + context: context, + initialDate: formDate.value ?? now, + firstDate: DateTime(now.year - 1), + lastDate: DateTime(now.year + 2), + ); + if (picked != null) formDate.value = _dateOnly(picked); + } + + String? _dateStr(DateTime? d) => d == null + ? null + : '${d.year}-${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}'; + + // ═════════════════════════════════════════════════════════════════ + // Szerkesztés + // ═════════════════════════════════════════════════════════════════ + + void beginEdit(VibroNavRoute r) { + _editing = r; + _drafts = []; + nameCtrl.text = r.name; + formDate.value = r.validDate; + formVehicle.value = r.vehicle; + formKind.value = r.routeKind; + formActive.value = r.isActive; + } + + Future toggleActive(VibroNavRoute r, bool value) async { + try { + await VibroNavRouteService.to.updateRoute(r.id, {'is_active': value}); + } catch (e) { + AppLogger.e('VibroNavRoutesController', 'Állapotváltás hiba: $e'); + Get.snackbar('Hiba', 'Az állapot módosítása nem sikerült: $e', + snackPosition: SnackPosition.BOTTOM); + } + } + + Future deleteRoute(VibroNavRoute r) async { + try { + await VibroNavRouteService.to.deleteRoute(r.id); + } catch (e) { + AppLogger.e('VibroNavRoutesController', 'Törlés hiba: $e'); + Get.snackbar('Hiba', 'A törlés nem sikerült: $e', + snackPosition: SnackPosition.BOTTOM); + } + } + + // ═════════════════════════════════════════════════════════════════ + // GeoJSON-import + // ═════════════════════════════════════════════════════════════════ + + /// Fájlválasztás + értelmezés. Visszatérés: a talált útvonalak száma, + /// vagy null, ha megszakadt / hibás a fájl (a hibát már kiírta). + Future pickAndParse() async { + if (projectId == null) { + Get.snackbar('Nincs aktív projekt', 'Előbb válassz ki egy projektet.', + snackPosition: SnackPosition.BOTTOM); + return null; + } + + // Az Android a .geojson kiterjesztést nem ismeri (Drive-ról sem + // választható FileType.custom-mal), ezért FileType.any + kézi ellenőrzés. + final result = await FilePicker.platform.pickFiles(type: FileType.any); + if (result == null || result.files.isEmpty) return null; + + final file = result.files.single; + final lower = file.name.toLowerCase(); + if (!lower.endsWith('.geojson') && !lower.endsWith('.json')) { + Get.snackbar('Nem GeoJSON', 'Válassz .geojson vagy .json fájlt.', + snackPosition: SnackPosition.BOTTOM); + return null; + } + + try { + String content; + if (file.bytes != null) { + content = utf8.decode(file.bytes!); + } else if (file.path != null) { + content = await File(file.path!).readAsString(); + } else { + throw const FormatException('A fájl nem olvasható.'); + } + + final base = file.name + .replaceAll(RegExp(r'\.(geo)?json$', caseSensitive: false), ''); + final parsed = VibroNavRouteService.parseGeoJson(content, base); + + _editing = null; + _drafts = parsed.drafts; + nameCtrl.clear(); + setDateOffset(0); // alapból a MAI napra érvényes + formVehicle.value = null; + formKind.value = 'detour'; + formActive.value = true; + + if (parsed.skipped > 0) { + Get.snackbar('Figyelem', + '${parsed.skipped} nem vonal geometriájú elem kimaradt.', + snackPosition: SnackPosition.BOTTOM); + } + return parsed.drafts.length; + } on FormatException catch (e) { + Get.snackbar('Import hiba', e.message, + snackPosition: SnackPosition.BOTTOM, + duration: const Duration(seconds: 6)); + return null; + } catch (e) { + AppLogger.e('VibroNavRoutesController', 'Import hiba: $e'); + Get.snackbar('Import hiba', '$e', snackPosition: SnackPosition.BOTTOM); + return null; + } + } + + // ═════════════════════════════════════════════════════════════════ + // Mentés (szerkesztés VAGY import) + // ═════════════════════════════════════════════════════════════════ + + Future saveForm() async { + final pid = projectId; + if (pid == null) return false; + + if (_editing != null && nameCtrl.text.trim().isEmpty) { + Get.snackbar('Hiányzó név', 'Adj nevet az útvonalnak.', + snackPosition: SnackPosition.BOTTOM); + return false; + } + + isBusy.value = true; + try { + final common = { + 'valid_date': _dateStr(formDate.value), + 'vehicle': formVehicle.value, + 'route_kind': formKind.value, + 'is_active': formActive.value, + }; + + if (_editing != null) { + await VibroNavRouteService.to.updateRoute( + _editing!.id, {'name': nameCtrl.text.trim(), ...common}); + } else { + await VibroNavRouteService.to.insertRoutes([ + for (final d in _drafts) + { + 'project_id': pid, + 'name': d.name, + 'geometry': d.geometry, + 'crs': d.crs, + ...common, + } + ]); + } + _editing = null; + _drafts = []; + return true; + } catch (e) { + AppLogger.e('VibroNavRoutesController', 'Mentési hiba: $e'); + Get.snackbar( + 'Mentési hiba', + 'Az útvonal nem menthető. Cloud-projekt kell hozzá, és be kell ' + 'lenned jelentkezve.\n$e', + snackPosition: SnackPosition.BOTTOM, + duration: const Duration(seconds: 6), + ); + return false; + } finally { + isBusy.value = false; + } + } +} diff --git a/lib/pages/vibro_nav/presentation/views/vibro_nav_routes_view.dart b/lib/pages/vibro_nav/presentation/views/vibro_nav_routes_view.dart new file mode 100644 index 0000000..f6f9079 --- /dev/null +++ b/lib/pages/vibro_nav/presentation/views/vibro_nav_routes_view.dart @@ -0,0 +1,325 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:terepi_seged/models/vibro_nav_route.dart'; +import 'package:terepi_seged/pages/vibro_nav/presentation/controllers/vibro_nav_routes_controller.dart'; +import 'package:terepi_seged/services/vechicle_identity_service.dart'; + +/// Útvonalak kezelése: lista, GeoJSON-import, szerkesztés, törlés. +/// Megnyitás: `VibroNavRoutesView.open()` (a binding-et is felveszi). +class VibroNavRoutesView extends GetView { + const VibroNavRoutesView({super.key}); + + static void open() { + Get.to( + () => const VibroNavRoutesView(), + binding: BindingsBuilder(() { + Get.put(VibroNavRoutesController()); + }), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Útvonalak')), + floatingActionButton: FloatingActionButton.extended( + icon: const Icon(Icons.upload_file), + label: const Text('GeoJSON import'), + onPressed: () async { + final n = await controller.pickAndParse(); + if (n != null) _showForm(); + }, + ), + body: Obx(() { + if (controller.isLoading.value) { + return const Center(child: CircularProgressIndicator()); + } + final list = controller.routes; + if (list.isEmpty) { + return const Center( + child: Padding( + padding: EdgeInsets.all(24), + child: Text( + 'Ehhez a projekthez még nincs útvonal.\n' + 'A "GeoJSON import" gombbal tölthetsz fel vonalakat, ' + 'vagy a PC-szoftver is feltöltheti őket.', + textAlign: TextAlign.center, + ), + ), + ); + } + return ListView.separated( + padding: const EdgeInsets.only(bottom: 88), + itemCount: list.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (_, i) => _RouteTile(route: list[i]), + ); + }), + ); + } + + static void _showForm() { + Get.bottomSheet( + const _RouteFormSheet(), + isScrollControlled: true, + ); + } +} + +class _RouteTile extends StatelessWidget { + final VibroNavRoute route; + const _RouteTile({required this.route}); + + @override + Widget build(BuildContext context) { + final c = VibroNavRoutesController.to; + final color = VibroNavRoute.colorFor(route.routeKind, route.color); + final today = DateTime.now(); + final isToday = VibroNavRoutesController.sameDay(route.validDate, today); + final isPast = route.validDate != null && + !isToday && + route.validDate!.isBefore(DateTime(today.year, today.month, today.day)); + + return ListTile( + leading: Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: color.withOpacity(route.isActive && !isPast ? 1 : 0.35), + shape: BoxShape.circle, + ), + child: const Icon(Icons.alt_route, color: Colors.white, size: 20), + ), + title: Text(route.name), + isThreeLine: true, + subtitle: Text.rich(TextSpan(children: [ + TextSpan( + text: isToday ? 'MA' : route.dateLabel, + style: TextStyle( + fontWeight: isToday ? FontWeight.w800 : FontWeight.w500, + color: isToday ? Colors.green.shade700 : null, + ), + ), + TextSpan( + text: ' · ${route.vehicleLabel} · ' + '${VibroNavRoute.kinds[route.routeKind] ?? route.routeKind}\n' + '${route.lines.length} vonal, ${route.pointCount} pont' + '${isPast ? ' · lejárt' : ''}'), + ])), + trailing: Switch( + value: route.isActive, + onChanged: (v) => c.toggleActive(route, v), + ), + onTap: () { + c.beginEdit(route); + VibroNavRoutesView._showForm(); + }, + ); + } +} + +/// Közös űrlap: import (több útvonal egyszerre) ÉS egy útvonal szerkesztése. +/// A lap alapja Material (nem Container(color:)), hogy a ListTile/chip +/// effektek ne takarodjanak el. +class _RouteFormSheet extends StatelessWidget { + const _RouteFormSheet(); + + @override + Widget build(BuildContext context) { + final c = VibroNavRoutesController.to; + final editing = c.isEditing; + + return Material( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(20)), + child: SafeArea( + child: Padding( + padding: EdgeInsets.fromLTRB( + 16, 16, 16, 16 + MediaQuery.of(context).viewInsets.bottom), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + editing + ? 'Útvonal szerkesztése' + : '${c.draftCount} útvonal importálása', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + if (editing) ...[ + TextField( + controller: c.nameCtrl, + decoration: const InputDecoration( + labelText: 'Név', + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 12), + ], + const _SectionTitle('Mely napon látszik?'), + Obx(() { + final d = c.formDate.value; + final isNone = d == null; + final isToday = + VibroNavRoutesController.sameDay(d, DateTime.now()); + final isTomorrow = VibroNavRoutesController.sameDay( + d, DateTime.now().add(const Duration(days: 1))); + final custom = !isNone && !isToday && !isTomorrow; + return Wrap( + spacing: 8, + children: [ + ChoiceChip( + label: const Text('Minden nap'), + selected: isNone, + onSelected: (_) => c.setDateNone(), + ), + ChoiceChip( + label: const Text('Ma'), + selected: isToday, + onSelected: (_) => c.setDateOffset(0), + ), + ChoiceChip( + label: const Text('Holnap'), + selected: isTomorrow, + onSelected: (_) => c.setDateOffset(1), + ), + ChoiceChip( + avatar: const Icon(Icons.event, size: 16), + label: Text(custom + ? '${d!.year}-${d.month.toString().padLeft(2, '0')}-' + '${d.day.toString().padLeft(2, '0')}' + : 'Dátum…'), + selected: custom, + onSelected: (_) => c.pickDate(context), + ), + ], + ); + }), + const SizedBox(height: 12), + const _SectionTitle('Melyik járműnek?'), + Obx(() => Wrap( + spacing: 8, + children: [ + ChoiceChip( + label: const Text('Minden jármű'), + selected: c.formVehicle.value == null, + onSelected: (_) => c.formVehicle.value = null, + ), + for (final v + in VehicleIdentityService.availableVehicles) + ChoiceChip( + label: Text(v), + selected: c.formVehicle.value == v, + onSelected: (_) => c.formVehicle.value = v, + ), + ], + )), + const SizedBox(height: 12), + const _SectionTitle('Fajta (a térképi szín)'), + Obx(() => Wrap( + spacing: 8, + children: [ + for (final e in VibroNavRoute.kinds.entries) + ChoiceChip( + avatar: CircleAvatar( + backgroundColor: + VibroNavRoute.colorFor(e.key, null), + radius: 6), + label: Text(e.value), + selected: c.formKind.value == e.key, + onSelected: (_) => c.formKind.value = e.key, + ), + ], + )), + const SizedBox(height: 4), + Obx(() => SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Aktív'), + value: c.formActive.value, + onChanged: (v) => c.formActive.value = v, + )), + const SizedBox(height: 8), + Obx(() => Row( + children: [ + if (editing) + TextButton.icon( + icon: const Icon(Icons.delete_outline, + color: Colors.red), + label: const Text('Törlés', + style: TextStyle(color: Colors.red)), + onPressed: c.isBusy.value + ? null + : () => _confirmDelete(context, c), + ), + const Spacer(), + TextButton( + onPressed: c.isBusy.value ? null : () => Get.back(), + child: const Text('Mégse'), + ), + const SizedBox(width: 8), + ElevatedButton( + onPressed: c.isBusy.value + ? null + : () async { + final ok = await c.saveForm(); + if (ok) Get.back(); + }, + child: c.isBusy.value + ? const SizedBox( + width: 18, + height: 18, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : Text(editing ? 'Mentés' : 'Importálás'), + ), + ], + )), + ], + ), + ), + ), + ), + ); + } + + Future _confirmDelete( + BuildContext context, VibroNavRoutesController c) async { + final route = c.editingRoute; + if (route == null) return; + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Útvonal törlése'), + content: Text('Biztosan törlöd: „${route.name}"?\n' + 'Minden járműről eltűnik.'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Mégse')), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Törlés')), + ], + ), + ); + if (ok == true) { + await c.deleteRoute(route); + Get.back(); + } + } +} + +class _SectionTitle extends StatelessWidget { + final String text; + const _SectionTitle(this.text); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Text(text, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700)), + ); +} diff --git a/lib/pages/vibro_nav/presentation/views/vibro_nav_view.dart b/lib/pages/vibro_nav/presentation/views/vibro_nav_view.dart index 4e192ca..782bcdd 100644 --- a/lib/pages/vibro_nav/presentation/views/vibro_nav_view.dart +++ b/lib/pages/vibro_nav/presentation/views/vibro_nav_view.dart @@ -4,7 +4,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:get/get.dart'; import 'package:latlong2/latlong.dart'; +import 'package:terepi_seged/models/vibro_nav_route.dart'; import 'package:terepi_seged/pages/vibro_nav/presentation/controllers/vibro_nav_controller.dart'; +import 'package:terepi_seged/pages/vibro_nav/presentation/views/vibro_nav_routes_view.dart'; import 'package:terepi_seged/services/gnss/gnss_connection.dart'; import 'package:terepi_seged/services/gnss/gnss_device_service.dart'; import 'package:terepi_seged/services/gnss/gnss_service.dart'; @@ -27,6 +29,11 @@ class VibroNavView extends GetView { style: const TextStyle(fontSize: 11), )), actions: [ + IconButton( + icon: const Icon(Icons.alt_route), + tooltip: 'Útvonalak kezelése', + onPressed: VibroNavRoutesView.open, + ), Obx(() => IconButton( icon: Icon(controller.showReservePoints.value ? Icons.circle_outlined @@ -181,6 +188,7 @@ class _NavMap extends StatelessWidget { subdomains: const ['mt0', 'mt1', 'mt2', 'mt3'], maxNativeZoom: 18), const _SourcePointsLayer(), + const _RoutesLayer(), Obx(() => controller.trail.length > 1 ? PolylineLayer(polylines: [ Polyline( @@ -648,3 +656,30 @@ class _OutlinedLabel extends StatelessWidget { ); } } + +/// A mai napra érvényes útvonalak (kerülők). Saját Obx-ben él, ezért +/// GNSS-frissítésre nem rajzolódik újra. +class _RoutesLayer extends StatelessWidget { + const _RoutesLayer(); + + @override + Widget build(BuildContext context) { + final c = VibroNavController.to; + return Obx(() { + final list = c.visibleRoutes; + if (list.isEmpty) return const SizedBox.shrink(); + return PolylineLayer(polylines: [ + for (final r in list) + for (final line in r.lines) + Polyline( + points: line, + strokeWidth: 5, + color: + VibroNavRoute.colorFor(r.routeKind, r.color).withOpacity(0.9), + borderStrokeWidth: 2, + borderColor: Colors.white, + ), + ]); + }); + } +} diff --git a/lib/services/nav_autostart_service.dart b/lib/services/nav_autostart_service.dart new file mode 100644 index 0000000..31cd695 --- /dev/null +++ b/lib/services/nav_autostart_service.dart @@ -0,0 +1,101 @@ +import 'dart:developer'; + +import 'package:flutter/services.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:terepi_seged/models/project.dart'; +import 'package:terepi_seged/services/app_database.dart'; +import 'package:terepi_seged/services/app_logger.dart'; + +/// Induláskor eldönti, hogy a navigációs képernyőt is meg kell-e nyitni. +/// +/// Mind a négy feltételnek teljesülnie kell: +/// 1. A felhasználó be van jelentkezve (a munkamenet a készüléken tárolt, +/// ez offline is eldönthető). +/// 2. Az utoljára aktív projekt FELHŐS (nem csak helyi), aktív, és már +/// szinkronizált (sync_status == 'synced'). +/// 3. A szerver elérhető — ezt maga a lekérdezés bizonyítja (offline hibára +/// fut). +/// 4. A `terepi_seged_app_config` táblában megtalálható a +/// `nav_autostart:` kulcs. Az érték tetszőleges (pl. 'igen'), +/// csak a kulcs megléte számít. A járművet (V1, V2…) NEM állítja be, az +/// marad a helyi érték. +/// +/// OFFLINE-FIRST: bármilyen hiba, időtúllépés vagy hiányzó adat esetén a +/// válasz `false`, az app a megszokott módon indul. Sosem dob kivételt. +class NavAutoStartService { + NavAutoStartService._(); + + static const keyPrefix = 'nav_autostart:'; + static const _channel = MethodChannel('hu.app_dev.terepi_seged/deviceInfo'); + + static bool _no(String reason) { + // ignore: avoid_print + print('🚀 NavAutoStart: NEM — $reason'); + return false; + } + + static Future shouldStartNavigation() async { + try { + final androidId = await _androidId(); + if (androidId == null || androidId.isEmpty) { + return _no('nincs ANDROID_ID'); + } + // ignore: avoid_print + print('🚀 NavAutoStart: androidId=$androidId'); + + if (Supabase.instance.client.auth.currentUser == null) { + return _no('nincs bejelentkezett felhasználó'); + } + + if (!await _activeProjectIsOnline()) return false; + + final row = await Supabase.instance.client + .from('terepi_seged_app_config') + .select('key') + .eq('key', '$keyPrefix$androidId') + .maybeSingle() + .timeout(const Duration(seconds: 3)); + if (row == null) { + return _no('nincs sor a kulccsal: $keyPrefix$androidId'); + } + + AppLogger.event('nav_autostart'); + // ignore: avoid_print + print('🚀 NavAutoStart: IGEN'); + return true; + } catch (e) { + return _no('hiba / időtúllépés: $e'); + } + } + + /// Az utoljára aktív projektet a ProjectService is a SharedPreferences-ből + /// állítja vissza, ezért itt közvetlenül azt vizsgáljuk — így nem függ a + /// ProjectService aszinkron indulásának időzítésétől. + static Future _activeProjectIsOnline() async { + final prefs = await SharedPreferences.getInstance(); + final id = prefs.getInt('active_project_id'); + if (id == null) return _no('nincs mentett aktív projekt'); + + final project = await AppDatabase.instance.getProject(id); + if (project == null) return _no('a projekt nincs a helyi adatbázisban'); + if (project.isLocalOnly) return _no('az aktív projekt csak helyi'); + if (project.status != ProjectStatus.active) { + return _no('a projekt nem aktív (${project.status})'); + } + + final sync = await AppDatabase.instance.getProjectSyncStatus(id); + if (sync != 'synced') return _no('sync_status=$sync (nem synced)'); + return true; + } + + /// Settings.Secure.ANDROID_ID — ugyanaz a csatorna, mint amit a + /// DeviceIdentityService használ. + static Future _androidId() async { + try { + return await _channel.invokeMethod('getAndroidId'); + } catch (_) { + return null; + } + } +} diff --git a/lib/services/vibro_nav_route_service.dart b/lib/services/vibro_nav_route_service.dart new file mode 100644 index 0000000..1bc05ce --- /dev/null +++ b/lib/services/vibro_nav_route_service.dart @@ -0,0 +1,253 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:get/get.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:terepi_seged/models/vibro_nav_route.dart'; +import 'package:terepi_seged/services/app_logger.dart'; + +/// Egy GeoJSON-ból kiolvasott, még NEM mentett útvonal. +class RouteDraft { + final String name; + final Map geometry; + final String crs; // 'wgs84' | 'eov' (az import eldönti) + const RouteDraft( + {required this.name, required this.geometry, required this.crs}); +} + +class RouteParseResult { + final List drafts; + final int skipped; // nem vonal geometriájú elemek (pont, terület…) + const RouteParseResult(this.drafts, this.skipped); +} + +/// Navigációs útvonalak (kerülők) — élő lekérdezés + szerkesztés. +/// +/// Az olvasás ugyanazt a bevált mintát követi, mint a forráspontoknál: +/// szerveroldalon szűrt lekérdezés + Realtime csatorna, ami CSAK jelzés +/// (debounce-olt újratöltés). A `.stream()`-et SZÁNDÉKOSAN kerüljük. +class VibroNavRouteService extends GetxService { + static VibroNavRouteService get to => Get.find(); + + static const _table = 'terepi_seged_vibro_nav_routes'; + + SupabaseClient get _client => Supabase.instance.client; + + /// A térképnek: csak az aktív, MAI napra érvényes vagy dátum nélküli + /// útvonalak. (A járműre szűrést a controller végzi, az a kis lista + /// miatt kliensoldalon is olcsó, és a járműváltásra azonnal reagál.) + Stream> watchForToday(String projectId) => + _watch(projectId, todayOnly: true); + + /// Az admin oldalnak: MINDEN útvonal, dátumtól és állapottól függetlenül. + Stream> watchAll(String projectId) => + _watch(projectId, todayOnly: false); + + Stream> _watch(String projectId, + {required bool todayOnly}) { + late StreamController> controller; + RealtimeChannel? channel; + Timer? debounce; + var disposed = false; + var fetching = false; + var again = false; + + Future refetch() async { + if (disposed) return; + if (fetching) { + again = true; + return; + } + fetching = true; + try { + do { + again = false; + final list = await _fetch(projectId, todayOnly); + if (!disposed) controller.add(list); + } while (again && !disposed); + } catch (e, s) { + if (!disposed) controller.addError(e, s); + } finally { + fetching = false; + } + } + + controller = StreamController>( + onListen: () { + refetch(); + channel = _client + .channel('vibro_routes_${projectId}_${todayOnly ? 'day' : 'all'}') + .onPostgresChanges( + event: PostgresChangeEvent.all, + schema: 'public', + table: _table, + filter: PostgresChangeFilter( + type: PostgresChangeFilterType.eq, + column: 'project_id', + value: projectId, + ), + callback: (_) { + debounce?.cancel(); + debounce = Timer(const Duration(milliseconds: 500), refetch); + }, + ) + .subscribe(); + }, + onCancel: () async { + disposed = true; + debounce?.cancel(); + if (channel != null) await _client.removeChannel(channel!); + }, + ); + return controller.stream; + } + + String _todayStr() { + final n = DateTime.now(); + return '${n.year}-${n.month.toString().padLeft(2, '0')}-' + '${n.day.toString().padLeft(2, '0')}'; + } + + Future> _fetch(String projectId, bool todayOnly) async { + final base = _client.from(_table).select().eq('project_id', projectId); + final filtered = todayOnly + ? base + .eq('is_active', true) + .or('valid_date.eq.${_todayStr()},valid_date.is.null') + : base; + + final rows = await filtered + .order('valid_date', ascending: false, nullsFirst: true) + .order('name') + .limit(500); + + final out = []; + for (final r in rows) { + try { + out.add(VibroNavRoute.fromMap(r)); + } catch (e) { + // Egyetlen hibás sor ne akassza meg az egész listát. + AppLogger.e('VibroNavRouteService', 'Hibás útvonal-sor kihagyva: $e'); + } + } + return out; + } + + // ═════════════════════════════════════════════════════════════════ + // Írás + // ═════════════════════════════════════════════════════════════════ + + Future insertRoutes(List> rows) async { + if (rows.isEmpty) return; + await _client.from(_table).insert(rows); + } + + Future updateRoute(String id, Map patch) async { + await _client.from(_table).update(patch).eq('id', id); + } + + Future deleteRoute(String id) async { + await _client.from(_table).delete().eq('id', id); + } + + // ═════════════════════════════════════════════════════════════════ + // GeoJSON-import + // ═════════════════════════════════════════════════════════════════ + + static const _nameKeys = [ + 'name', + 'nev', + 'név', + 'megnevezes', + 'megnevezés', + 'title', + ]; + + /// FeatureCollection / Feature / puszta geometria → vonal-útvonalak. + /// Csak LineString és MultiLineString kerül be; a többit megszámolja. + /// A koordinátarendszert (WGS84 / EOV) fájlonként a nagyságrend dönti el. + static RouteParseResult parseGeoJson(String content, String fallbackName) { + final dynamic json; + try { + json = jsonDecode(content); + } catch (e) { + throw FormatException('Érvénytelen GeoJSON (nem elemezhető JSON): $e'); + } + + // Minden elemet (geometria + tulajdonságok) egységes listába szedünk. + final items = + <({Map geometry, Map props})>[]; + + void addGeometry(dynamic g, dynamic props) { + if (g is Map) { + items.add(( + geometry: Map.from(g), + props: props is Map ? Map.from(props) : const {}, + )); + } + } + + if (json is Map && json['type'] == 'FeatureCollection') { + final features = json['features']; + if (features is List) { + for (final f in features) { + if (f is Map) addGeometry(f['geometry'], f['properties']); + } + } + } else if (json is Map && json['type'] == 'Feature') { + addGeometry(json['geometry'], json['properties']); + } else if (json is Map && json['type'] != null) { + addGeometry(json, null); + } + + if (items.isEmpty) { + throw const FormatException('Nem található geometria a GeoJSON-ban.'); + } + + final drafts = []; + var skipped = 0; + var counter = 0; + + for (final it in items) { + final type = it.geometry['type']; + if (type != 'LineString' && type != 'MultiLineString') { + skipped++; + continue; + } + // A geometria legyen értelmezhető (legalább egy 2 pontos vonal). + final crs = VibroNavRoute.detectCrs(it.geometry); + final lines = VibroNavRoute.toWgsLines(it.geometry, crs); + if (lines.isEmpty) { + skipped++; + continue; + } + counter++; + drafts.add(RouteDraft( + name: _nameFrom(it.props) ?? '$fallbackName $counter', + geometry: { + 'type': type, + 'coordinates': it.geometry['coordinates'], + }, + crs: crs, + )); + } + + if (drafts.isEmpty) { + throw const FormatException( + 'A fájl nem tartalmaz érvényes vonal (LineString / MultiLineString) ' + 'geometriát.'); + } + return RouteParseResult(drafts, skipped); + } + + static String? _nameFrom(Map props) { + final lower = {for (final e in props.entries) e.key.toLowerCase(): e.value}; + for (final k in _nameKeys) { + final v = lower[k]; + if (v != null && v.toString().trim().isNotEmpty) { + return v.toString().trim(); + } + } + return null; + } +}