diff --git a/lib/main.dart b/lib/main.dart index a6c8f11..77999ea 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -30,6 +30,7 @@ import 'package:terepi_seged/services/stakeout_sync_service.dart'; import 'package:terepi_seged/services/tilt_service.dart'; 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'; Future main() async { @@ -101,6 +102,7 @@ Future main() async { Get.put(TiltService()); Get.put(PermissionService()); Get.put(ContactService()); + Get.put(VehicleIdentityService()); runApp(const MyApp()); } diff --git a/lib/models/sensor_chanel.dart b/lib/models/sensor_chanel.dart new file mode 100644 index 0000000..a3694c4 --- /dev/null +++ b/lib/models/sensor_chanel.dart @@ -0,0 +1,91 @@ +import 'package:uuid/uuid.dart'; + +/// Érzékelő-csatorna: a MŰSZER geometriája — melyik csatornaszám melyik +/// vonal/állomás fizikai pontjához tartozik. +/// +/// SZÁNDÉKOSAN önálló modell (nem a StakeoutPoint kiterjesztése): az +/// adat forrása lehet SPS-import (a műszer geometria-exportja), de +/// származhat máshonnan is (kézi rögzítés, jövőbeli más formátum). +/// A kitűzési (GNSS-szel mért) pontokkal a (lineId, station) párossal +/// vetjük össze FUTÁSIDŐBEN (lásd StakeoutService), nem tárolunk +/// másolatot a mért pozícióból — így sosem megy szét a két adat. +class SensorChannel { + final int? id; + final String uuid; + final int projectId; + + final int channel; + final String lineId; + final int station; + + /// Terv-pozíció az SPS R-fájlból (ha volt hozzá tartozó vevőpont-sor). + /// Null, ha csak a csatorna-hozzárendelés ismert (pl. csak X-fájl volt), + /// ilyenkor a GNSS-mért kitűzési pont adja az egyetlen pozíciót. + final double? planEovY; + final double? planEovX; + final double? planLat; + final double? planLon; + + final String source; // 'sps' | 'manual' + final String? importBatch; // egy import-menet azonosítója (törléshez) + + final DateTime createdAt; + final DateTime updatedAt; + + SensorChannel({ + this.id, + String? uuid, + required this.projectId, + required this.channel, + required this.lineId, + required this.station, + this.planEovY, + this.planEovX, + this.planLat, + this.planLon, + this.source = 'sps', + this.importBatch, + DateTime? createdAt, + DateTime? updatedAt, + }) : uuid = uuid ?? const Uuid().v4(), + createdAt = createdAt ?? DateTime.now(), + updatedAt = updatedAt ?? DateTime.now(); + + bool get hasPlanPosition => planEovY != null && planEovX != null; + + Map toMap() => { + if (id != null) 'id': id, + 'uuid': uuid, + 'project_id': projectId, + 'channel': channel, + 'line_id': lineId, + 'station': station, + 'plan_eov_y': planEovY, + 'plan_eov_x': planEovX, + 'plan_lat': planLat, + 'plan_lon': planLon, + 'source': source, + 'import_batch': importBatch, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + }; + + factory SensorChannel.fromMap(Map m) => SensorChannel( + id: m['id'] as int?, + uuid: m['uuid'] as String, + projectId: m['project_id'] as int, + channel: m['channel'] as int, + lineId: (m['line_id'] as String?) ?? '', + station: m['station'] as int, + planEovY: (m['plan_eov_y'] as num?)?.toDouble(), + planEovX: (m['plan_eov_x'] as num?)?.toDouble(), + planLat: (m['plan_lat'] as num?)?.toDouble(), + planLon: (m['plan_lon'] as num?)?.toDouble(), + source: (m['source'] as String?) ?? 'sps', + importBatch: m['import_batch'] as String?, + createdAt: DateTime.tryParse((m['created_at'] as String?) ?? '') ?? + DateTime.now(), + updatedAt: DateTime.tryParse((m['updated_at'] as String?) ?? '') ?? + DateTime.now(), + ); +} diff --git a/lib/models/source_point.dart b/lib/models/source_point.dart new file mode 100644 index 0000000..a4099c2 --- /dev/null +++ b/lib/models/source_point.dart @@ -0,0 +1,74 @@ +import 'package:uuid/uuid.dart'; + +/// Forráspont (vibrátor-állomás) — az SPS S-fájlból importált TERV-pozíció. +/// +/// Tisztán referencia-réteg: nincs "kijelölve/meglőve" mutálható állapota +/// (a döntés szerint a tényleges ellenőrzés a periodikus pozíciónapló és +/// a terv UTÓLAGOS összevetéséből adódik — lásd VibratorNavController). +class SourcePoint { + final int? id; + final String uuid; + final int projectId; + + final String lineId; + final int station; // VP (vibration point) szám + + final double planEovY; + final double planEovX; + final double planLat; + final double planLon; + + final String source; // 'sps' + final String? importBatch; + + final DateTime createdAt; + + SourcePoint({ + this.id, + String? uuid, + required this.projectId, + required this.lineId, + required this.station, + required this.planEovY, + required this.planEovX, + required this.planLat, + required this.planLon, + this.source = 'sps', + this.importBatch, + DateTime? createdAt, + }) : uuid = uuid ?? const Uuid().v4(), + createdAt = createdAt ?? DateTime.now(); + + String get displayId => '$lineId · $station'; + + Map toMap() => { + if (id != null) 'id': id, + 'uuid': uuid, + 'project_id': projectId, + 'line_id': lineId, + 'station': station, + 'plan_eov_y': planEovY, + 'plan_eov_x': planEovX, + 'plan_lat': planLat, + 'plan_lon': planLon, + 'source': source, + 'import_batch': importBatch, + 'created_at': createdAt.toIso8601String(), + }; + + factory SourcePoint.fromMap(Map m) => SourcePoint( + id: m['id'] as int?, + uuid: m['uuid'] as String, + projectId: m['project_id'] as int, + lineId: (m['line_id'] as String?) ?? '', + station: m['station'] as int, + planEovY: (m['plan_eov_y'] as num).toDouble(), + planEovX: (m['plan_eov_x'] as num).toDouble(), + planLat: (m['plan_lat'] as num).toDouble(), + planLon: (m['plan_lon'] as num).toDouble(), + source: (m['source'] as String?) ?? 'sps', + importBatch: m['import_batch'] as String?, + createdAt: DateTime.tryParse((m['created_at'] as String?) ?? '') ?? + DateTime.now(), + ); +} diff --git a/lib/models/vechicle_position_log.dart b/lib/models/vechicle_position_log.dart new file mode 100644 index 0000000..a96436a --- /dev/null +++ b/lib/models/vechicle_position_log.dart @@ -0,0 +1,87 @@ +import 'package:uuid/uuid.dart'; + +/// Periodikus járműpozíció-minta — a navigációs oldal a beállított +/// időközönként ide ment egy sort, amíg a rögzítés fut. Ebből (és a +/// SourcePoint terv-pozíciókból) számítható utólag, mely forráspontok +/// mellett járt ténylegesen a jármű. +class VehiclePositionLog { + final int? id; + final String uuid; + final int projectId; + + final String vehicleId; // pl. "V1" / "V2" / "V3" + + final double eovY; + final double eovX; + final double lat; + final double lon; + final double? altitude; + final double? speedKmh; + final double? heading; + final int? fixQuality; + final double? accuracy; + + final DateTime timestamp; + final String? deviceId; + final String? appInstanceId; + + VehiclePositionLog({ + this.id, + String? uuid, + required this.projectId, + required this.vehicleId, + required this.eovY, + required this.eovX, + required this.lat, + required this.lon, + this.altitude, + this.speedKmh, + this.heading, + this.fixQuality, + this.accuracy, + DateTime? timestamp, + this.deviceId, + this.appInstanceId, + }) : uuid = uuid ?? const Uuid().v4(), + timestamp = timestamp ?? DateTime.now(); + + Map toMap() => { + if (id != null) 'id': id, + 'uuid': uuid, + 'project_id': projectId, + 'vehicle_id': vehicleId, + 'eov_y': eovY, + 'eov_x': eovX, + 'lat': lat, + 'lon': lon, + 'altitude': altitude, + 'speed_kmh': speedKmh, + 'heading': heading, + 'fix_quality': fixQuality, + 'accuracy': accuracy, + 'timestamp': timestamp.toIso8601String(), + 'device_id': deviceId, + 'app_instance_id': appInstanceId, + }; + + factory VehiclePositionLog.fromMap(Map m) => + VehiclePositionLog( + id: m['id'] as int?, + uuid: m['uuid'] as String, + projectId: m['project_id'] as int, + vehicleId: (m['vehicle_id'] as String?) ?? '', + eovY: (m['eov_y'] as num).toDouble(), + eovX: (m['eov_x'] as num).toDouble(), + lat: (m['lat'] as num).toDouble(), + lon: (m['lon'] as num).toDouble(), + altitude: (m['altitude'] as num?)?.toDouble(), + speedKmh: (m['speed_kmh'] as num?)?.toDouble(), + heading: (m['heading'] as num?)?.toDouble(), + fixQuality: m['fix_quality'] as int?, + accuracy: (m['accuracy'] as num?)?.toDouble(), + timestamp: DateTime.tryParse((m['timestamp'] as String?) ?? '') ?? + DateTime.now(), + deviceId: m['device_id'] as String?, + appInstanceId: m['app_instance_id'] as String?, + ); +} diff --git a/lib/pages/vibro_nav/presentation/controllers/vibro_nav_controller.dart b/lib/pages/vibro_nav/presentation/controllers/vibro_nav_controller.dart new file mode 100644 index 0000000..d4e1b60 --- /dev/null +++ b/lib/pages/vibro_nav/presentation/controllers/vibro_nav_controller.dart @@ -0,0 +1,216 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:get/get.dart'; +import 'package:terepi_seged/models/source_point.dart'; +import 'package:terepi_seged/models/vechicle_position_log.dart'; +import 'package:terepi_seged/services/app_database.dart'; +import 'package:terepi_seged/services/coord_converter_service.dart'; +import 'package:terepi_seged/services/device_identity_service.dart'; +import 'package:terepi_seged/services/gnss/gnss_service.dart'; +import 'package:terepi_seged/services/project_service.dart'; +import 'package:terepi_seged/services/vechicle_identity_service.dart'; + +/// Egyszerű navigációs panel a jelgerjesztő (vibrátor) járműhöz. +/// +/// A pozíció a KITŰZÉSSEL AZONOS forrásból jön (GnssService — külső +/// BT/BLE GNSS-egység a járműben), semmilyen új eszköz-integráció nem +/// kell hozzá. A forráspontok tisztán referencia-réteg (nincs mutálható +/// "meglőve" állapotuk); az ellenőrzés a periodikus pozíciónapló és a +/// terv UTÓLAGOS összevetéséből adódik (lásd isVerified). +class VibroNavController extends GetxController { + AppDatabase get _db => AppDatabase.instance; + + // ── Forráspontok ───────────────────────────────────────────────── + final sourcePoints = [].obs; + + /// Egyezés-tűrés (m) a naplózott pozíció és a terv-forráspont között. + final tolerance = 15.0.obs; + + // ── Élő pozíció (a GnssService-ből, EOV-ra konvertálva) ─────────── + final hasPosition = false.obs; + final curEovY = 0.0.obs; + final curEovX = 0.0.obs; + final speedKmh = 0.0.obs; + final sessionDistanceM = 0.0.obs; + + /// Haladási irány (fok) — pozíció-előzményből számolva, ALACSONY + /// SEBESSÉGNÉL BEFAGYASZTVA (a nyers GPS-heading álló/lassú helyzetben + /// zajos/értelmetlen — ugyanez a minta, mint a Kitűzésnél). + final travelHeading = Rxn(); + static const _headingFreezeSpeedKmh = 5.0; + + double? _histY, _histX; + DateTime? _histTime; + + // ── Legközelebbi forráspont ─────────────────────────────────────── + final nearestPoint = Rxn(); + final nearestDistance = 0.0.obs; + + // ── Periodikus napló ─────────────────────────────────────────────── + final isLogging = false.obs; + final logIntervalSec = 60.obs; // felhasználó által állítható + Timer? _logTimer; + + @override + void onInit() { + super.onInit(); + load(); + if (Get.isRegistered()) { + ever(GnssService.to.lastGgaLine, (_) => _onPosition()); + } + } + + @override + void onClose() { + _logTimer?.cancel(); + super.onClose(); + } + + Future load() async { + final projectId = ProjectService.to.activeProjectId; + if (projectId == null) { + sourcePoints.clear(); + return; + } + sourcePoints.value = await _db.listSourcePoints(projectId); + } + + // ═════════════════════════════════════════════════════════════════ + // Pozíció-feldolgozás + // ═════════════════════════════════════════════════════════════════ + + void _onPosition() { + final gnss = GnssService.to; + if (gnss.gpsQuality.value <= 0 || + gnss.latitude.value == 0 || + !Get.isRegistered()) { + return; + } + + final p = CoordConverterService.to + .wgsToEovPoint(gnss.longitude.value, gnss.latitude.value); + final now = DateTime.now(); + + if (_histY != null && _histTime != null) { + final dy = p.x - _histY!; + final dx = p.y - _histX!; + final dist = math.sqrt(dy * dy + dx * dx); + final dtSec = now.difference(_histTime!).inMilliseconds / 1000.0; + + if (dtSec > 0) { + final v = dist / dtSec; // m/s + speedKmh.value = v * 3.6; + sessionDistanceM.value += dist; + + // Irány csak akkor frissül, ha a sebesség a fagyasztási küszöb + // felett van — álló/nagyon lassú helyzetben a nyers irány zajos. + if (speedKmh.value >= _headingFreezeSpeedKmh && dist > 0.3) { + travelHeading.value = + (math.atan2(dy, dx) * 180 / math.pi + 360) % 360; + } + } + } + + _histY = p.x; + _histX = p.y; + _histTime = now; + + curEovY.value = p.x; + curEovX.value = p.y; + hasPosition.value = true; + + _updateNearest(); + } + + void _updateNearest() { + if (sourcePoints.isEmpty) { + nearestPoint.value = null; + return; + } + SourcePoint? best; + var bestDist = double.infinity; + for (final sp in sourcePoints) { + final dy = sp.planEovY - curEovY.value; + final dx = sp.planEovX - curEovX.value; + final d = math.sqrt(dy * dy + dx * dx); + if (d < bestDist) { + bestDist = d; + best = sp; + } + } + nearestPoint.value = best; + nearestDistance.value = bestDist; + } + + // ═════════════════════════════════════════════════════════════════ + // Terv/napló összevetés — melyik forráspont "igazolt" + // ═════════════════════════════════════════════════════════════════ + + /// Egyszerű, szinkron ellenőrzés a már betöltött [logs] lista alapján + /// (a nézet előre lekéri, hogy ne fusson adatbázis-lekérdezés minden + /// egyes marker kirajzolásakor). + bool isVerified(SourcePoint sp, List logs) { + for (final log in logs) { + final dy = sp.planEovY - log.eovY; + final dx = sp.planEovX - log.eovX; + if (math.sqrt(dy * dy + dx * dx) <= tolerance.value) return true; + } + return false; + } + + Future> loadLogs() async { + final projectId = ProjectService.to.activeProjectId; + if (projectId == null) return []; + return _db.listVehiclePositionLogs(projectId); + } + + // ═════════════════════════════════════════════════════════════════ + // Periodikus rögzítés + // ═════════════════════════════════════════════════════════════════ + + void startLogging() { + if (isLogging.value) return; + isLogging.value = true; + _logTick(); // azonnali első pont + _logTimer = Timer.periodic( + Duration(seconds: logIntervalSec.value), (_) => _logTick()); + } + + void stopLogging() { + isLogging.value = false; + _logTimer?.cancel(); + _logTimer = null; + } + + Future _logTick() async { + if (!hasPosition.value) return; + final projectId = ProjectService.to.activeProjectId; + final vehicleId = VehicleIdentityService.to.selectedVehicle.value; + if (projectId == null || vehicleId == null) return; + + final gnss = GnssService.to; + final entry = VehiclePositionLog( + projectId: projectId, + vehicleId: vehicleId, + eovY: curEovY.value, + eovX: curEovX.value, + lat: gnss.latitude.value, + lon: gnss.longitude.value, + altitude: gnss.altitude.value, + speedKmh: speedKmh.value, + heading: travelHeading.value, + fixQuality: gnss.gpsQuality.value, + accuracy: gnss.horizontalAccuracy, + deviceId: Get.isRegistered() && + DeviceIdentityService.to.isReady + ? DeviceIdentityService.to.deviceId + : null, + appInstanceId: Get.isRegistered() && + DeviceIdentityService.to.isReady + ? DeviceIdentityService.to.appInstanceId + : null, + ); + await _db.insertVehiclePositionLog(entry); + } +} diff --git a/lib/pages/vibro_nav/presentation/views/vibro_nav_view.dart b/lib/pages/vibro_nav/presentation/views/vibro_nav_view.dart new file mode 100644 index 0000000..be726e7 --- /dev/null +++ b/lib/pages/vibro_nav/presentation/views/vibro_nav_view.dart @@ -0,0 +1,456 @@ +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +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/source_point.dart'; +import 'package:terepi_seged/models/vechicle_position_log.dart'; +import 'package:terepi_seged/services/app_database.dart'; +import 'package:terepi_seged/services/coord_converter_service.dart'; +import 'package:terepi_seged/services/gnss/gnss_service.dart'; +import 'package:terepi_seged/services/project_service.dart'; +import 'package:terepi_seged/services/sps_import_service.dart'; +import 'package:terepi_seged/services/vechicle_identity_service.dart'; +import 'package:terepi_seged/widgets/map/imported_layer_overlay.dart'; + +import '../controllers/vibro_nav_controller.dart'; + +/// Egyszerű navigációs oldal a vibrátor-járműhöz. ÖNÁLLÓ oldal, nem +/// MapSurveyMode — saját MapController kell a forgó (track-up) +/// térképhez, hogy ez semmilyen más módot ne érintsen. +class VibroNavView extends StatefulWidget { + const VibroNavView({super.key}); + + @override + State createState() => _VibroNavViewState(); +} + +class _VibroNavViewState extends State { + late final VibroNavController controller; + final _mapController = MapController(); + List _logs = []; + + @override + void initState() { + super.initState(); + controller = Get.put(VibroNavController()); + _refreshLogs(); + // Forgatás + a naplók frissítése a pozíció-változásokra. + ever(controller.travelHeading, (_) => _applyRotation()); + ever(controller.hasPosition, (_) => _followPosition()); + } + + @override + void dispose() { + Get.delete(); + super.dispose(); + } + + Future _refreshLogs() async { + _logs = await controller.loadLogs(); + if (mounted) setState(() {}); + } + + void _applyRotation() { + final h = controller.travelHeading.value; + if (h == null) return; + try { + _mapController.rotate(-h); + } catch (_) {} + } + + void _followPosition() { + if (!controller.hasPosition.value || + !Get.isRegistered()) { + return; + } + final gnss = GnssService.to; + try { + _mapController.move(LatLng(gnss.latitude.value, gnss.longitude.value), + _mapController.camera.zoom); + } catch (_) {} + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Vibrátor navigáció'), + actions: [ + IconButton( + icon: const Icon(Icons.upload_file), + tooltip: 'Forráspontok importja (SPS S-fájl)', + onPressed: _importSourcePoints, + ), + IconButton( + icon: const Icon(Icons.local_shipping_outlined), + tooltip: 'Jármű kiválasztása', + onPressed: _pickVehicle, + ), + ], + ), + body: Stack( + children: [ + _buildMap(), + Positioned( + left: 8, + right: 8, + top: 8, + child: _VehicleBadge(), + ), + Positioned( + left: 8, + right: 8, + bottom: 8, + child: _NavPanel( + controller: controller, onLoggingToggled: _refreshLogs), + ), + ], + ), + ); + } + + Widget _buildMap() { + return Obx(() { + final points = controller.sourcePoints; + final nearest = controller.nearestPoint.value; + + return FlutterMap( + mapController: _mapController, + options: const MapOptions( + initialCenter: LatLng(47.5, 19.05), + initialZoom: 15, + ), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'hu.app_dev.terepi_seged', + ), + // Tervezett útvonal + minden más importált háttérréteg — a + // MEGLÉVŐ rétegimport-mechanizmus, nincs hozzá új kód. + const ImportedLayerOverlay(), + MarkerLayer(markers: [ + for (final sp in points) + Marker( + point: LatLng(sp.planLat, sp.planLon), + width: 40, + height: 40, + child: _SourcePointMarker( + point: sp, + isNearest: sp.uuid == nearest?.uuid, + isVerified: controller.isVerified(sp, _logs), + ), + ), + if (controller.hasPosition.value) + Marker( + point: LatLng(GnssService.to.latitude.value, + GnssService.to.longitude.value), + width: 34, + height: 34, + child: const _VehicleMarker(), + ), + ]), + ], + ); + }); + } + + // ── Jármű-választás ──────────────────────────────────────────────── + + Future _pickVehicle() async { + final chosen = await Get.dialog(AlertDialog( + title: const Text('Melyik járműben van ez a tablet?'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final v in VehicleIdentityService.availableVehicles) + RadioListTile( + title: Text(v), + value: v, + groupValue: VehicleIdentityService.to.selectedVehicle.value, + onChanged: (val) => Get.back(result: val), + ), + ], + ), + )); + if (chosen != null) { + await VehicleIdentityService.to.setVehicle(chosen); + } + } + + // ── SPS S-fájl (forráspont) import — kompakt, egyetlen dialógus ──── + + Future _importSourcePoints() async { + final result = await FilePicker.platform.pickFiles(type: FileType.any); + final path = result?.files.single.path; + if (path == null) return; + + final projectId = ProjectService.to.activeProjectId; + if (projectId == null) return; + + try { + final content = await File(path).readAsString(); + final points = SpsParser.parseSourceFile(content); + if (points.isEmpty) { + Get.snackbar( + 'Import', + 'Nem sikerült forráspontot beolvasni ebből a fájlból — ' + 'ellenőrizd, hogy valódi SPS S-fájl-e.', + snackPosition: SnackPosition.BOTTOM); + return; + } + + final withPos = points.where((p) => p.eovY != null && p.eovX != null); + final ok = await Get.dialog(AlertDialog( + title: const Text('Forráspontok importja'), + content: Text('${points.length} pont az S-fájlban, ' + '${withPos.length} érvényes koordinátával.\n\n' + 'Első néhány: ${points.take(3).map((p) => '${p.lineId}·${p.station}').join(', ')}…'), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text('Mégse')), + FilledButton( + onPressed: () => Get.back(result: true), + child: const Text('Import')), + ], + )); + if (ok != true) return; + + final conv = CoordConverterService.to; + final batch = DateTime.now().toIso8601String(); + final saved = []; + for (final p in withPos) { + final w = conv.eovToWgsPoint(p.eovY!, p.eovX!); + saved.add(SourcePoint( + projectId: projectId, + lineId: p.lineId, + station: p.station, + planEovY: p.eovY!, + planEovX: p.eovX!, + planLat: w.y, + planLon: w.x, + importBatch: batch, + )); + } + final inserted = await AppDatabase.instance.insertSourcePoints(saved); + await controller.load(); + Get.snackbar('Import kész', '$inserted forráspont importálva', + snackPosition: SnackPosition.BOTTOM); + } catch (e) { + Get.snackbar('Import hiba', e.toString(), + snackPosition: SnackPosition.BOTTOM); + } + } +} + +// ═══════════════════════════════════════════════════════════════════ +// HUD panel: sebesség, táv, legközelebbi pont, rögzítés vezérlés +// ═══════════════════════════════════════════════════════════════════ + +class _NavPanel extends StatelessWidget { + final VibroNavController controller; + final VoidCallback onLoggingToggled; + const _NavPanel({required this.controller, required this.onLoggingToggled}); + + @override + Widget build(BuildContext context) { + return Obx(() { + final nearest = controller.nearestPoint.value; + return Card( + elevation: 6, + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _Stat( + label: 'Sebesség', + value: + '${controller.speedKmh.value.toStringAsFixed(0)} km/h'), + _Stat( + label: 'Megtett táv', + value: _fmtDist(controller.sessionDistanceM.value)), + if (nearest != null) + _Stat( + label: 'Legközelebbi VP', + value: nearest.displayId, + highlight: true), + if (nearest != null) + _Stat( + label: 'Táv odáig', + value: _fmtDist(controller.nearestDistance.value)), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + Expanded( + child: controller.isLogging.value + ? OutlinedButton.icon( + icon: const Icon(Icons.stop_circle_outlined), + label: Text('Rögzítés leállítása ' + '(${controller.logIntervalSec.value} mp)'), + onPressed: () { + controller.stopLogging(); + onLoggingToggled(); + }, + ) + : FilledButton.icon( + icon: const Icon(Icons.fiber_manual_record, + color: Colors.red), + label: Text('Rögzítés indítása ' + '(${controller.logIntervalSec.value} mp-enként)'), + onPressed: () { + controller.startLogging(); + onLoggingToggled(); + }, + ), + ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.timer_outlined), + tooltip: 'Rögzítési időköz', + onPressed: controller.isLogging.value + ? null + : () => _pickInterval(controller), + ), + ], + ), + ], + ), + ), + ); + }); + } + + Future _pickInterval(VibroNavController c) async { + final opts = [15, 30, 60, 120, 300]; + final chosen = await Get.dialog(AlertDialog( + title: const Text('Rögzítési időköz'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final s in opts) + RadioListTile( + title: Text(s < 60 ? '$s másodperc' : '${s ~/ 60} perc'), + value: s, + groupValue: c.logIntervalSec.value, + onChanged: (v) => Get.back(result: v), + ), + ], + ), + )); + if (chosen != null) c.logIntervalSec.value = chosen; + } + + static String _fmtDist(double m) => m < 1000 + ? '${m.toStringAsFixed(0)} m' + : '${(m / 1000).toStringAsFixed(2)} km'; +} + +class _Stat extends StatelessWidget { + final String label; + final String value; + final bool highlight; + const _Stat( + {required this.label, required this.value, this.highlight = false}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(value, + style: TextStyle( + fontSize: highlight ? 16 : 18, + fontWeight: FontWeight.w700, + color: + highlight ? Theme.of(context).colorScheme.primary : null)), + Text(label, + style: TextStyle(fontSize: 10, color: Colors.grey.shade600)), + ], + ); + } +} + +// ═══════════════════════════════════════════════════════════════════ + +class _VehicleBadge extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Obx(() { + final v = VehicleIdentityService.to.selectedVehicle.value; + return Align( + alignment: Alignment.topLeft, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: v == null + ? Colors.orange.withOpacity(0.9) + : Colors.black.withOpacity(0.7), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + v == null ? 'Nincs jármű kiválasztva!' : 'Jármű: $v', + style: const TextStyle(color: Colors.white, fontSize: 12), + ), + ), + ); + }); + } +} + +class _SourcePointMarker extends StatelessWidget { + final SourcePoint point; + final bool isNearest; + final bool isVerified; + const _SourcePointMarker( + {required this.point, required this.isNearest, required this.isVerified}); + + @override + Widget build(BuildContext context) { + final color = isVerified ? Colors.green : Colors.grey.shade600; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + isVerified ? Icons.check_circle : Icons.radio_button_unchecked, + color: isNearest ? Colors.red : color, + size: isNearest ? 26 : 18, + ), + Text(point.displayId, + style: TextStyle( + fontSize: 9, + fontWeight: isNearest ? FontWeight.w700 : FontWeight.w400, + color: isNearest ? Colors.red : Colors.black87)), + ], + ); + } +} + +class _VehicleMarker extends StatelessWidget { + const _VehicleMarker(); + + @override + Widget build(BuildContext context) { + // Track-up módban a szimbólum mindig "felfelé" mutat — a világ + // forog körülötte, nem a szimbólum a világ körül. + return Container( + decoration: BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 3), + boxShadow: [ + BoxShadow(color: Colors.blue.withOpacity(0.5), blurRadius: 8) + ], + ), + child: const Icon(Icons.navigation, color: Colors.white, size: 18), + ); + } +} diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index 15abf82..f8fc772 100644 --- a/lib/routes/app_pages.dart +++ b/lib/routes/app_pages.dart @@ -27,6 +27,7 @@ import 'package:terepi_seged/pages/start/bindings/start_page_bindings.dart'; import 'package:terepi_seged/pages/start/presentation/views/start_page.dart'; import 'package:terepi_seged/pages/tracking/bindings/tracking_bindings.dart'; import 'package:terepi_seged/pages/tracking/presentation/views/tracking_view.dart'; +import 'package:terepi_seged/pages/vibro_nav/presentation/views/vibro_nav_view.dart'; import '../pages/map_test/bindings/map_test_bindings.dart'; import '../pages/map_test/presentation/views/map_test_view.dart'; @@ -106,6 +107,7 @@ class AppPages { GetPage(name: Routes.SETTINGS, page: () => const SettingsView()), GetPage( name: Routes.STAKEOUT_IMPORT, page: () => const StakeoutImportView()), - GetPage(name: Routes.CONTACTS, page: () => const ContactsView()) + GetPage(name: Routes.CONTACTS, page: () => const ContactsView()), + GetPage(name: Routes.VIBRONAV, page: () => const VibroNavView()) ]; } diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index e7509fd..5bd52be 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -26,4 +26,5 @@ abstract class Routes { static const SETTINGS = '/settings'; static const STAKEOUT_IMPORT = '/stakeout_import'; + static const VIBRONAV = '/vibro_nav'; } diff --git a/lib/services/app_database.dart b/lib/services/app_database.dart index 2e03ac3..e5582a5 100644 --- a/lib/services/app_database.dart +++ b/lib/services/app_database.dart @@ -11,8 +11,10 @@ import 'package:terepi_seged/models/measured_point.dart'; import 'package:terepi_seged/models/note_item.dart'; import 'package:terepi_seged/models/note_item_audio.dart'; import 'package:terepi_seged/models/note_item_photo.dart'; +import 'package:terepi_seged/models/source_point.dart'; import 'package:terepi_seged/models/stakeout_point.dart'; import 'package:terepi_seged/models/track.dart'; +import 'package:terepi_seged/models/vechicle_position_log.dart'; import 'package:terepi_seged/services/device_identity_service.dart'; import 'package:uuid/uuid.dart'; import '../models/project.dart'; @@ -44,7 +46,7 @@ class AppDatabase { final path = p.join(dbDir.path, 'terepi_seged.db'); return openDatabase(path, - version: 5, + version: 6, onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: _onCreate, onUpgrade: _onUpgrade); @@ -271,6 +273,8 @@ class AppDatabase { await _createContactsOutbox(db); await _addAppInstanceIdColumns(db); + await _createVibratorNavTables(db); + // Alap projekt létrehozása az első indításhoz final now = DateTime.now().toIso8601String(); await db.insert('projects', { @@ -303,10 +307,11 @@ class AppDatabase { await _migrateToV4(db); } if (oldVersion < 5) { - _createContactsOutbox(db); + await _createContactsOutbox(db); } await _addAppInstanceIdColumns(db); + await _createVibratorNavTables(db); } Future _migrateToV4(Database db) async { @@ -1392,4 +1397,84 @@ class AppDatabase { // await db.execute('CREATE INDEX IF NOT EXISTS idx_contacts_outbox_project ' // 'ON contacts_outbox(project_id)'); // } + + // ── Vibrátor navigáció: forráspontok + járműpozíció-napló ──────── + + Future _createVibratorNavTables(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS source_points ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, + project_id INTEGER NOT NULL, + line_id TEXT NOT NULL DEFAULT '', + station INTEGER NOT NULL, + plan_eov_y REAL NOT NULL, + plan_eov_x REAL NOT NULL, + plan_lat REAL NOT NULL, + plan_lon REAL NOT NULL, + source TEXT NOT NULL DEFAULT 'sps', + import_batch TEXT, + created_at TEXT NOT NULL + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_source_points_project ' + 'ON source_points(project_id, line_id, station)'); + + await db.execute(''' + CREATE TABLE IF NOT EXISTS vehicle_position_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + uuid TEXT NOT NULL UNIQUE, + project_id INTEGER NOT NULL, + vehicle_id TEXT NOT NULL, + eov_y REAL NOT NULL, + eov_x REAL NOT NULL, + lat REAL NOT NULL, + lon REAL NOT NULL, + altitude REAL, + speed_kmh REAL, + heading REAL, + fix_quality INTEGER, + accuracy REAL, + timestamp TEXT NOT NULL, + device_id TEXT, + app_instance_id TEXT + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_vehicle_logs_project ' + 'ON vehicle_position_logs(project_id, vehicle_id, timestamp)'); + } + + Future insertSourcePoints(List points) async { + final db = await database; + var count = 0; + await db.transaction((txn) async { + for (final p in points) { + await txn.insert('source_points', p.toMap()); + count++; + } + }); + return count; + } + + Future> listSourcePoints(int projectId) async { + final db = await database; + final rows = await db.query('source_points', + where: 'project_id = ?', + whereArgs: [projectId], + orderBy: 'station ASC'); + return rows.map(SourcePoint.fromMap).toList(); + } + + Future insertVehiclePositionLog(VehiclePositionLog log) async { + final db = await database; + await db.insert('vehicle_position_logs', log.toMap()); + } + + Future> listVehiclePositionLogs( + int projectId) async { + final db = await database; + final rows = await db.query('vehicle_position_logs', + where: 'project_id = ?', whereArgs: [projectId]); + return rows.map(VehiclePositionLog.fromMap).toList(); + } } diff --git a/lib/services/sps_import_service.dart b/lib/services/sps_import_service.dart new file mode 100644 index 0000000..10b2d1f --- /dev/null +++ b/lib/services/sps_import_service.dart @@ -0,0 +1,320 @@ +import 'package:terepi_seged/models/sensor_chanel.dart'; +import 'package:terepi_seged/services/coord_converter_service.dart'; + +/// SPS (Shell Processing Support) fájlok beolvasása — a SEG 1993-as +/// "SPS Format for Land 3D Surveys" specifikációja szerint, fix +/// oszlop-pozíciókkal (1-alapú, záró oszlop is beleértve). +/// +/// Csak azt olvassuk ki, ami a csatorna-geometria ellenőrzéséhez kell: +/// * R-fájl (Receiver "Point Record"): vonal, pontszám, EOV Y/X, magasság +/// * X-fájl (Relation Record): csatorna-tartomány → vonal + állomás-tartomány +/// +/// A fix oszlopszélességű, évtizedes szabvány gyártónként kicsit eltérő +/// exportokat is szülhet — ezért import előtt MINDIG előnézet van +/// (lásd SpsImportPreview), soha nem mentünk vakon. +class SpsParser { + SpsParser._(); + + // ── Nyers sor-kivágás (1-alapú, záró oszlop is benne) ───────────── + static String _col(String line, int from, int to) { + if (line.length < from) return ''; + final end = line.length < to ? line.length : to; + return line.substring(from - 1, end).trim(); + } + + static bool _isDataLine(String line, String expectedFirstChar) { + if (line.isEmpty) return false; + if (line.startsWith('EOF')) return false; + if (line[0] == 'H') return false; // fejléc/komment sor + return line[0].toUpperCase() == expectedFirstChar; + } + + // ═════════════════════════════════════════════════════════════════ + // R-fájl (vevőpont) — "Point Record", cols 1-80 + // ═════════════════════════════════════════════════════════════════ + // 1 Rekord-azonosító 1-1 "R" + // 2 Vonalnév 2-17 + // 3 Pontszám 18-25 + // 4 Pont-index 26-26 + // 11 EOV Y (easting) 47-55 + // 12 EOV X (northing) 56-65 + // 13 Magasság 66-71 + + static List parseReceiverFile(String content) => + _parsePointFile(content, 'R'); + + /// Forráspontok (vibrátor-állomások) — az S-fájl UGYANAZT az + /// oszlop-elrendezést használja, mint az R-fájl, csak a rekord-jelölő + /// betű más. + static List parseSourceFile(String content) => + _parsePointFile(content, 'S'); + + static List _parsePointFile( + String content, String recordType) { + final result = []; + for (final raw in content.split(RegExp(r'\r\n|\r|\n'))) { + if (!_isDataLine(raw, recordType)) continue; + final lineId = _col(raw, 2, 17); + final pointStr = _col(raw, 18, 25); + final indexStr = _col(raw, 26, 26); + final eastingStr = _col(raw, 47, 55); + final northingStr = _col(raw, 56, 65); + final elevStr = _col(raw, 66, 71); + + // A pontszám ritkán tartalmazhat törtrészt — az egész részt vesszük + // állomásszámként. + final pointNum = + int.tryParse(pointStr.split('.').first.replaceAll(RegExp(r'\D'), '')); + if (pointNum == null) continue; + + result.add(SpsPointRecord( + lineId: lineId, + station: pointNum, + pointIndex: int.tryParse(indexStr) ?? 1, + eovY: double.tryParse(eastingStr), + eovX: double.tryParse(northingStr), + elevation: double.tryParse(elevStr), + rawLine: raw, + )); + } + return result; + } + + // ═════════════════════════════════════════════════════════════════ + // X-fájl (kapcsolat) — "Relation Record", cols 1-80 + // ═════════════════════════════════════════════════════════════════ + // 9 From channel 39-42 + // 10 To channel 43-46 + // 11 Channel increment 47-47 + // 12 Vevő-vonalnév 48-63 + // 13 From receiver 64-71 + // 14 To receiver 72-79 + // 15 Receiver index 80-80 + + static List parseRelationFile(String content) { + final result = []; + for (final raw in content.split(RegExp(r'\r\n|\r|\n'))) { + if (!_isDataLine(raw, 'X')) continue; + + final fromCh = int.tryParse(_col(raw, 39, 42)); + final toCh = int.tryParse(_col(raw, 43, 46)); + final chInc = int.tryParse(_col(raw, 47, 47)) ?? 1; + final recvLine = _col(raw, 48, 63); + final fromRecv = int.tryParse(_col(raw, 64, 71)); + final toRecv = int.tryParse(_col(raw, 72, 79)); + + if (fromCh == null || + toCh == null || + fromRecv == null || + toRecv == null) { + continue; + } + + result.add(SpsRelationRecord( + fromChannel: fromCh, + toChannel: toCh, + channelIncrement: chInc <= 0 ? 1 : chInc, + recvLineId: recvLine, + fromReceiver: fromRecv, + toReceiver: toRecv, + rawLine: raw, + )); + } + return result; + } + + /// Egy X-rekord (csatorna-TARTOMÁNY) egyedi (csatorna, vonal, állomás) + /// hármasokra bontása. A normál (egykomponensű) esetben a csatorna- és + /// vevőszám párhuzamosan fut végig a tartományon. Többkomponensű + /// (channel increment > 1) esetet — ritka, pl. 3C geofonoknál — nem + /// bontunk szét channelenként külön állomásra, mert az UGYANAHHOZ az + /// egy fizikai ponthoz tartozna; ilyenkor a tartomány KEZDŐ csatornáját + /// társítjuk a ponthoz, a többit átugorjuk. + static List<({int channel, String lineId, int station})> expandRelation( + SpsRelationRecord r) { + final out = <({int channel, String lineId, int station})>[]; + if (r.channelIncrement != 1) { + out.add(( + channel: r.fromChannel, + lineId: r.recvLineId, + station: r.fromReceiver + )); + return out; + } + final chCount = r.toChannel - r.fromChannel; + final recvCount = r.toReceiver - r.fromReceiver; + if (chCount < 0) return out; + for (var i = 0; i <= chCount; i++) { + final station = chCount == 0 + ? r.fromReceiver + : r.fromReceiver + (recvCount * i / chCount).round(); + out.add( + (channel: r.fromChannel + i, lineId: r.recvLineId, station: station)); + } + return out; + } + + // ═════════════════════════════════════════════════════════════════ + // Előnézet összeállítása — R + X összefésülve + // ═════════════════════════════════════════════════════════════════ + + /// [receiverPoints] és/vagy [relations] közül legalább az egyik legyen + /// nem üres. Ha csak relations van, a csatorna-hozzárendelés megvan, + /// de terv-koordináta nélkül (a GNSS-mért kitűzési pont adja majd a + /// pozíciót). Ha csak receiverPoints van, nincs csatornaszám — ekkor + /// channel = null marad minden sorban (a UI jelzi, hogy ez hiányos). + static SpsImportPreview buildPreview({ + List receiverPoints = const [], + List relations = const [], + }) { + final byLineStation = {}; + for (final p in receiverPoints) { + byLineStation['${p.lineId}|${p.station}'] = p; + } + + final rows = []; + var unmatchedChannels = 0; + + if (relations.isNotEmpty) { + for (final rel in relations) { + for (final e in expandRelation(rel)) { + final match = byLineStation['${e.lineId}|${e.station}']; + if (match == null) unmatchedChannels++; + rows.add(SpsPreviewRow( + channel: e.channel, + lineId: e.lineId, + station: e.station, + eovY: match?.eovY, + eovX: match?.eovX, + )); + } + } + } else { + // Csak R-fájl: nincs csatornaszám, csak a vevőpontok listája. + for (final p in receiverPoints) { + rows.add(SpsPreviewRow( + channel: null, + lineId: p.lineId, + station: p.station, + eovY: p.eovY, + eovX: p.eovX, + )); + } + } + + rows.sort( + (a, b) => (a.channel ?? a.station).compareTo(b.channel ?? b.station)); + + return SpsImportPreview( + rows: rows, + totalReceiverPoints: receiverPoints.length, + totalRelations: relations.length, + unmatchedChannelCount: unmatchedChannels, + ); + } + + /// A jóváhagyott előnézetből SensorChannel lista építése (mentés előtt). + static List buildSensorChannels({ + required SpsImportPreview preview, + required int projectId, + required String importBatch, + }) { + final conv = CoordConverterService.to; + final out = []; + for (final r in preview.rows) { + if (r.channel == null) continue; // csatornaszám nélkül nincs mit menteni + double? lat, lon; + if (r.eovY != null && r.eovX != null) { + final w = conv.eovToWgsPoint(r.eovY!, r.eovX!); + lon = w.x; + lat = w.y; + } + out.add(SensorChannel( + projectId: projectId, + channel: r.channel!, + lineId: r.lineId, + station: r.station, + planEovY: r.eovY, + planEovX: r.eovX, + planLat: lat, + planLon: lon, + source: 'sps', + importBatch: importBatch, + )); + } + return out; + } +} + +// ═════════════════════════════════════════════════════════════════════ +// Adatszerkezetek +// ═════════════════════════════════════════════════════════════════════ + +class SpsPointRecord { + final String lineId; + final int station; + final int pointIndex; + final double? eovY; + final double? eovX; + final double? elevation; + final String rawLine; + SpsPointRecord({ + required this.lineId, + required this.station, + required this.pointIndex, + this.eovY, + this.eovX, + this.elevation, + required this.rawLine, + }); +} + +class SpsRelationRecord { + final int fromChannel; + final int toChannel; + final int channelIncrement; + final String recvLineId; + final int fromReceiver; + final int toReceiver; + final String rawLine; + SpsRelationRecord({ + required this.fromChannel, + required this.toChannel, + required this.channelIncrement, + required this.recvLineId, + required this.fromReceiver, + required this.toReceiver, + required this.rawLine, + }); +} + +/// Egy előnézeti sor — ez jelenik meg a felhasználónak import előtt. +class SpsPreviewRow { + final int? channel; + final String lineId; + final int station; + final double? eovY; + final double? eovX; + SpsPreviewRow({ + required this.channel, + required this.lineId, + required this.station, + this.eovY, + this.eovX, + }); + + bool get hasPosition => eovY != null && eovX != null; +} + +class SpsImportPreview { + final List rows; + final int totalReceiverPoints; + final int totalRelations; + final int unmatchedChannelCount; + SpsImportPreview({ + required this.rows, + required this.totalReceiverPoints, + required this.totalRelations, + required this.unmatchedChannelCount, + }); +} diff --git a/lib/services/vechicle_identity_service.dart b/lib/services/vechicle_identity_service.dart new file mode 100644 index 0000000..adc50ee --- /dev/null +++ b/lib/services/vechicle_identity_service.dart @@ -0,0 +1,29 @@ +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Melyik járműben van EZ a tablet — eszköz-szintű, tartós beállítás +/// (nem projektfüggő). Több tabletnél/eszközcserénél is egyszerűen +/// újra beállítható, ha a tablet másik járműbe kerül. +class VehicleIdentityService extends GetxService { + static VehicleIdentityService get to => Get.find(); + + static const _key = 'vehicle_id'; + + /// Az alapértelmezett választható lista — igény szerint bővíthető. + static const availableVehicles = ['V1', 'V2', 'V3']; + + final selectedVehicle = Rxn(); + + @override + Future onInit() async { + super.onInit(); + final prefs = await SharedPreferences.getInstance(); + selectedVehicle.value = prefs.getString(_key); + } + + Future setVehicle(String vehicleId) async { + selectedVehicle.value = vehicleId; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_key, vehicleId); + } +} diff --git a/lib/widgets/app_drawer.dart b/lib/widgets/app_drawer.dart index 6878cc0..3d1fcda 100644 --- a/lib/widgets/app_drawer.dart +++ b/lib/widgets/app_drawer.dart @@ -145,6 +145,14 @@ class AppDrawer extends StatelessWidget { // Get.to(() => const NtripSettingsView()); }, ), + ListTile( + leading: const Icon(Icons.local_shipping_outlined), + title: const Text('Jármű navigáció'), + onTap: () { + Get.back(); + Get.toNamed(Routes.VIBRONAV); + }, + ), // ── 3. Beállítások ───────────────────────────────── const _SectionLabel('Beállítások'),