diff --git a/lib/main.dart b/lib/main.dart index 455e512..1ff96c8 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -21,6 +21,7 @@ import 'package:terepi_seged/services/note_audio_service.dart'; import 'package:terepi_seged/services/note_photo_service.dart'; import 'package:terepi_seged/services/ntrip_service.dart'; import 'package:terepi_seged/services/project_service.dart'; +import 'package:terepi_seged/services/stakeout_service.dart'; import 'package:terepi_seged/services/track_sync_service.dart'; Future main() async { @@ -43,6 +44,7 @@ Future main() async { url: dotenv.env['SUPABASE_URL']!, anonKey: dotenv.env['SUPABASE_ANON_KEY']!); + Get.put(AuthService()); await AppDatabase.instance.database; Get.put(ProjectService(), permanent: true); @@ -52,6 +54,7 @@ Future main() async { () => CoordConverterService().init()); Get.put(GnssDeviceService()); Get.put(GnssService()); + Get.put(StakeoutService()); Get.put(NtripService()); NtripService.to.onRtcmData = (data) => GnssService.to.sendToReceiver(data); Get.put(TrackingController(), permanent: true); @@ -60,7 +63,6 @@ Future main() async { Get.put(LayerImportService(), permanent: true); Get.put(DeviceIdentityService(), permanent: true); Get.put(TrackSyncService(), permanent: true); - Get.put(AuthService()); runApp(const MyApp()); } diff --git a/lib/models/stakeout_point.dart b/lib/models/stakeout_point.dart new file mode 100644 index 0000000..f11ef8a --- /dev/null +++ b/lib/models/stakeout_point.dart @@ -0,0 +1,251 @@ +import 'package:uuid/uuid.dart'; + +/// Kitűzési pont állapota. +enum StakeoutStatus { pending, staked, skipped } + +/// Kitűzendő pont — szeizmikus line/station modellel. +/// +/// A pont természetes azonosítója a (lineId, station) páros. A rekord a +/// TERV-koordinátákat ÉS a kitűzéskori MÉRT pozíciót is tárolja, az +/// inline/crossline eltérésekkel együtt — így az export nemcsak azt +/// dokumentálja, MIT tűztünk ki, hanem azt is, MILYEN pontossággal. +/// +/// A séma szinkron-kész (uuid, updated_at, deleted_at, sync_status, +/// created_by, device_id) — a Supabase-oldal a 4. ütemben épül rá. +class StakeoutPoint { + final int? id; + final String uuid; + final int projectId; + + // ── Identitás ──────────────────────────────────────────────────── + final String lineId; // szeizmikus vonal azonosító ('' = vonal nélküli) + final int station; // numerikus állomásszám (kötelező!) + final String name; // megjelenítendő név (alapból a station szövege) + final String pointType; // geofon / forras / egyeb — SPS-re előkészítve + final String source; // csv / geojson / supabase / offset / kezi + + // ── Terv-koordináták (mindkét rendszerben tárolva) ─────────────── + final double planEovY; + final double planEovX; + final double? planEovZ; + final double planLat; + final double planLon; + + // ── Állapot + mért adatok ──────────────────────────────────────── + final StakeoutStatus status; + final double? measuredEovY; + final double? measuredEovX; + final double? measuredEovZ; + final double? measuredLat; + final double? measuredLon; + final double? + devInline; // vonal menti eltérés (m, + = station-növekedés felé) + final double? devCrossline; // vonalra merőleges eltérés (m, + = jobbra) + final double? devDz; // magassági eltérés (m, terv − mért) + final int? fixQuality; // GGA quality tároláskor (4 = RTK fixed) + final double? accuracy; // vízszintes hiba tároláskor (m) + final double? tiltDeg; // rúd-dőlés tároláskor (később: libella-modul) + final DateTime? stakedAt; + + // ── Eltolt (transzverzális) pont ───────────────────────────────── + final bool isOffset; + final String? parentUuid; // az eredeti tervpont uuid-ja + final double? offsetDist; // eltolás nagysága (m) + final double? offsetBearing; // eltolás iránya (fok, EOV-észak = 0) + + // ── Szinkron / audit ───────────────────────────────────────────── + final String? createdBy; + final String? deviceId; + final DateTime createdAt; + final DateTime updatedAt; + final DateTime? deletedAt; + final String syncStatus; + + StakeoutPoint({ + this.id, + String? uuid, + required this.projectId, + this.lineId = '', + required this.station, + String? name, + this.pointType = 'geofon', + this.source = 'csv', + required this.planEovY, + required this.planEovX, + this.planEovZ, + required this.planLat, + required this.planLon, + this.status = StakeoutStatus.pending, + this.measuredEovY, + this.measuredEovX, + this.measuredEovZ, + this.measuredLat, + this.measuredLon, + this.devInline, + this.devCrossline, + this.devDz, + this.fixQuality, + this.accuracy, + this.tiltDeg, + this.stakedAt, + this.isOffset = false, + this.parentUuid, + this.offsetDist, + this.offsetBearing, + this.createdBy, + this.deviceId, + DateTime? createdAt, + DateTime? updatedAt, + this.deletedAt, + this.syncStatus = 'pending', + }) : uuid = uuid ?? const Uuid().v4(), + name = name ?? station.toString(), + createdAt = createdAt ?? DateTime.now(), + updatedAt = updatedAt ?? DateTime.now(); + + /// Teljes azonosító megjelenítésre: "L1024 · 105" vagy csak "105". + String get displayId => lineId.isEmpty ? name : '$lineId · $name'; + + StakeoutPoint copyWith({ + int? id, + StakeoutStatus? status, + double? measuredEovY, + double? measuredEovX, + double? measuredEovZ, + double? measuredLat, + double? measuredLon, + double? devInline, + double? devCrossline, + double? devDz, + int? fixQuality, + double? accuracy, + double? tiltDeg, + DateTime? stakedAt, + DateTime? updatedAt, + String? syncStatus, + }) { + return StakeoutPoint( + id: id ?? this.id, + uuid: uuid, + projectId: projectId, + lineId: lineId, + station: station, + name: name, + pointType: pointType, + source: source, + planEovY: planEovY, + planEovX: planEovX, + planEovZ: planEovZ, + planLat: planLat, + planLon: planLon, + status: status ?? this.status, + measuredEovY: measuredEovY ?? this.measuredEovY, + measuredEovX: measuredEovX ?? this.measuredEovX, + measuredEovZ: measuredEovZ ?? this.measuredEovZ, + measuredLat: measuredLat ?? this.measuredLat, + measuredLon: measuredLon ?? this.measuredLon, + devInline: devInline ?? this.devInline, + devCrossline: devCrossline ?? this.devCrossline, + devDz: devDz ?? this.devDz, + fixQuality: fixQuality ?? this.fixQuality, + accuracy: accuracy ?? this.accuracy, + tiltDeg: tiltDeg ?? this.tiltDeg, + stakedAt: stakedAt ?? this.stakedAt, + isOffset: isOffset, + parentUuid: parentUuid, + offsetDist: offsetDist, + offsetBearing: offsetBearing, + createdBy: createdBy, + deviceId: deviceId, + createdAt: createdAt, + updatedAt: updatedAt ?? DateTime.now(), + deletedAt: deletedAt, + syncStatus: syncStatus ?? 'pending', + ); + } + + Map toMap() => { + if (id != null) 'id': id, + 'uuid': uuid, + 'project_id': projectId, + 'line_id': lineId, + 'station': station, + 'name': name, + 'point_type': pointType, + 'source': source, + 'plan_eov_y': planEovY, + 'plan_eov_x': planEovX, + 'plan_eov_z': planEovZ, + 'plan_lat': planLat, + 'plan_lon': planLon, + 'status': status.name, + 'measured_eov_y': measuredEovY, + 'measured_eov_x': measuredEovX, + 'measured_eov_z': measuredEovZ, + 'measured_lat': measuredLat, + 'measured_lon': measuredLon, + 'dev_inline': devInline, + 'dev_crossline': devCrossline, + 'dev_dz': devDz, + 'fix_quality': fixQuality, + 'accuracy': accuracy, + 'tilt_deg': tiltDeg, + 'staked_at': stakedAt?.toIso8601String(), + 'is_offset': isOffset ? 1 : 0, + 'parent_uuid': parentUuid, + 'offset_dist': offsetDist, + 'offset_bearing': offsetBearing, + 'created_by': createdBy, + 'device_id': deviceId, + 'created_at': createdAt.toIso8601String(), + 'updated_at': updatedAt.toIso8601String(), + 'deleted_at': deletedAt?.toIso8601String(), + 'sync_status': syncStatus, + }; + + factory StakeoutPoint.fromMap(Map m) => StakeoutPoint( + 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, + name: (m['name'] as String?) ?? '${m['station']}', + pointType: (m['point_type'] as String?) ?? 'geofon', + source: (m['source'] as String?) ?? 'csv', + planEovY: (m['plan_eov_y'] as num).toDouble(), + planEovX: (m['plan_eov_x'] as num).toDouble(), + planEovZ: (m['plan_eov_z'] as num?)?.toDouble(), + planLat: (m['plan_lat'] as num).toDouble(), + planLon: (m['plan_lon'] as num).toDouble(), + status: StakeoutStatus.values.firstWhere((s) => s.name == m['status'], + orElse: () => StakeoutStatus.pending), + measuredEovY: (m['measured_eov_y'] as num?)?.toDouble(), + measuredEovX: (m['measured_eov_x'] as num?)?.toDouble(), + measuredEovZ: (m['measured_eov_z'] as num?)?.toDouble(), + measuredLat: (m['measured_lat'] as num?)?.toDouble(), + measuredLon: (m['measured_lon'] as num?)?.toDouble(), + devInline: (m['dev_inline'] as num?)?.toDouble(), + devCrossline: (m['dev_crossline'] as num?)?.toDouble(), + devDz: (m['dev_dz'] as num?)?.toDouble(), + fixQuality: m['fix_quality'] as int?, + accuracy: (m['accuracy'] as num?)?.toDouble(), + tiltDeg: (m['tilt_deg'] as num?)?.toDouble(), + stakedAt: m['staked_at'] != null + ? DateTime.tryParse(m['staked_at'] as String) + : null, + isOffset: (m['is_offset'] as int? ?? 0) == 1, + parentUuid: m['parent_uuid'] as String?, + offsetDist: (m['offset_dist'] as num?)?.toDouble(), + offsetBearing: (m['offset_bearing'] as num?)?.toDouble(), + createdBy: m['created_by'] as String?, + deviceId: m['device_id'] as String?, + createdAt: DateTime.tryParse((m['created_at'] as String?) ?? '') ?? + DateTime.now(), + updatedAt: DateTime.tryParse((m['updated_at'] as String?) ?? '') ?? + DateTime.now(), + deletedAt: m['deleted_at'] != null + ? DateTime.tryParse(m['deleted_at'] as String) + : null, + syncStatus: (m['sync_status'] as String?) ?? 'pending', + ); +} diff --git a/lib/pages/map_survey/presentations/controllers/map_survey_controller.dart b/lib/pages/map_survey/presentations/controllers/map_survey_controller.dart index 5c49cdd..ac18a30 100644 --- a/lib/pages/map_survey/presentations/controllers/map_survey_controller.dart +++ b/lib/pages/map_survey/presentations/controllers/map_survey_controller.dart @@ -48,6 +48,7 @@ import 'package:terepi_seged/services/gnss/gnss_device_service.dart'; import 'package:terepi_seged/services/gnss/gnss_service.dart'; import 'package:terepi_seged/services/ntrip_service.dart'; import 'package:terepi_seged/services/project_service.dart'; +import 'package:terepi_seged/services/stakeout_service.dart'; import 'package:terepi_seged/widgets/map/all_layer_overlay.dart'; import 'package:terepi_seged/widgets/map/imported_layer_overlay.dart'; import 'package:terepi_seged/widgets/map/team_member_widget.dart'; @@ -452,6 +453,12 @@ class MapSurveyController extends GetxController implements StyleEditable { // - térképi tap viselkedés // - aktív kártyák // - track indítás/leállítás figyelmeztetés stb. + + // A kitűzési mód: a StakeoutService aktiválása/deaktiválása + // aktiváláskor betölti a pontokat és indul a haptika/fázis logika + if (Get.isRegistered()) { + StakeoutService.to.setActive(newMode == MapSurveyMode.stakeout); + } } String get currentModeLabel => switch (mode.value) { diff --git a/lib/pages/map_survey/presentations/views/map_survey_view.dart b/lib/pages/map_survey/presentations/views/map_survey_view.dart index 4ce637d..e9680f7 100644 --- a/lib/pages/map_survey/presentations/views/map_survey_view.dart +++ b/lib/pages/map_survey/presentations/views/map_survey_view.dart @@ -9,6 +9,8 @@ import 'package:terepi_seged/enums/map_measure_type.dart'; import 'package:terepi_seged/enums/map_survey_mode.dart'; import 'package:terepi_seged/pages/map_survey/presentations/controllers/map_survey_controller.dart'; import 'package:terepi_seged/pages/map_survey/presentations/views/settings_dialog.dart'; +import 'package:terepi_seged/pages/map_survey/presentations/widgets/stakeout_map_layer.dart'; +import 'package:terepi_seged/pages/map_survey/presentations/widgets/stakeout_panel.dart'; import 'package:terepi_seged/pages/tracking/presentation/controllers/tracking_controller.dart'; import 'package:terepi_seged/services/gnss/gnss_device_service.dart'; import 'package:terepi_seged/services/gnss/gnss_service.dart'; @@ -240,6 +242,7 @@ class MapSurveyView extends GetView { return MarkerLayer(markers: markers); }), DistanceOrAreaMeasureLayer(controller: controller), + const StakeoutMapLayers(), Obx(() { final isGpsActive = GnssService.to.activeConnectionType.value != GnssConnectionType.none; @@ -335,7 +338,14 @@ class MapSurveyView extends GetView { ), ] ])); - }) + }), + Obx(() { + if (controller.mode.value != MapSurveyMode.stakeout) { + return SizedBox.shrink(); + } + return const Positioned( + left: 0, right: 0, bottom: 0, child: StakeoutPanel()); + }), // Positioned(top: 8, left: 0, right: 0, child: _ModeSelector()), // Positioned( // bottom: 80, diff --git a/lib/pages/map_survey/presentations/widgets/stakeout_map_layer.dart b/lib/pages/map_survey/presentations/widgets/stakeout_map_layer.dart new file mode 100644 index 0000000..15d768b --- /dev/null +++ b/lib/pages/map_survey/presentations/widgets/stakeout_map_layer.dart @@ -0,0 +1,125 @@ +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/services/gnss/gnss_service.dart'; +import 'package:terepi_seged/services/stakeout_service.dart'; + +import '../../../../models/stakeout_point.dart'; + +/// Kitűzési térképréteg — a SharedMapWidget children listájába illesztendő. +/// +/// Csak akkor rajzol, ha a StakeoutService aktív (mode == stakeout), így +/// feltétel nélkül bent maradhat a rétegek között. Tartalma: vonalanként +/// polyline (station-sorrendben), státusz-színezett markerek (koppintás = +/// célváltás), vezetővonal a pozíciótól a célig. +class StakeoutMapLayers extends StatelessWidget { + const StakeoutMapLayers({super.key}); + + @override + Widget build(BuildContext context) { + if (!Get.isRegistered()) return const SizedBox.shrink(); + + return Obx(() { + final svc = StakeoutService.to; + if (!svc.active.value || svc.points.isEmpty) { + return const SizedBox.shrink(); + } + + final points = svc.points; + final target = svc.target.value; + final g = GnssService.to; + final hasPos = svc.hasPosition.value && g.latitude.value != 0; + + // Vonalanként polyline (station-sorrendben, eltolt pontok nélkül). + final linePolys = []; + for (final line in svc.lines) { + final lp = points.where((p) => p.lineId == line && !p.isOffset).toList() + ..sort((a, b) => a.station.compareTo(b.station)); + if (lp.length < 2) continue; + linePolys.add(Polyline( + points: [for (final p in lp) LatLng(p.planLat, p.planLon)], + color: Colors.blueGrey.withOpacity(0.5), + strokeWidth: 2, + )); + } + + // 300 pont fölött a címkék csak a célon (teljesítmény). + final showLabels = points.length <= 300; + + return Stack(children: [ + PolylineLayer(polylines: [ + ...linePolys, + if (hasPos && target != null) + Polyline( + points: [ + LatLng(g.latitude.value, g.longitude.value), + LatLng(target.planLat, target.planLon), + ], + color: Colors.blue, + strokeWidth: 3, + ), + ]), + MarkerLayer(markers: [ + for (final p in points) + Marker( + point: LatLng(p.planLat, p.planLon), + width: 56, + height: 40, + child: GestureDetector( + onTap: () => svc.setTarget(p), + child: _StakeoutMarker( + point: p, + isTarget: p.uuid == target?.uuid, + showLabel: showLabels || p.uuid == target?.uuid, + ), + ), + ), + ]), + ]); + }); + } +} + +class _StakeoutMarker extends StatelessWidget { + final StakeoutPoint point; + final bool isTarget; + final bool showLabel; + const _StakeoutMarker( + {required this.point, required this.isTarget, required this.showLabel}); + + @override + Widget build(BuildContext context) { + final (color, icon) = switch (point.status) { + StakeoutStatus.staked => (Colors.green, Icons.check_circle), + StakeoutStatus.skipped => (Colors.grey, Icons.block), + StakeoutStatus.pending => point.isOffset + ? (Colors.purple, Icons.change_history) + : (Colors.deepOrange, Icons.change_history), + }; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, + size: isTarget ? 26 : 16, color: isTarget ? Colors.red : color), + if (showLabel) + Container( + padding: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.85), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + point.name, + style: TextStyle( + fontSize: 9, + fontWeight: isTarget ? FontWeight.w700 : FontWeight.w400, + color: isTarget ? Colors.red : Colors.black87, + ), + ), + ), + ], + ); + } +} diff --git a/lib/pages/map_survey/presentations/widgets/stakeout_panel.dart b/lib/pages/map_survey/presentations/widgets/stakeout_panel.dart new file mode 100644 index 0000000..692c853 --- /dev/null +++ b/lib/pages/map_survey/presentations/widgets/stakeout_panel.dart @@ -0,0 +1,610 @@ +import 'dart:math' as math; +import 'dart:ui' show FontFeature; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:terepi_seged/routes/app_pages.dart'; +import 'package:terepi_seged/services/gnss/gnss_service.dart'; +import 'package:terepi_seged/services/stakeout_service.dart'; + +/// Kitűzési alsó panel — a map_survey nézet overlay-jébe illesztendő +/// (mode == MapSurveyMode.stakeout esetén). Kétfázisú: 5 m felett nagy +/// irány-nyíl + távolság, alatta céltábla cm-es eltérésekkel. A tárolás, +/// kihagyás és transzverzális eltolás gombjai is itt vannak. +class StakeoutPanel extends StatelessWidget { + const StakeoutPanel({super.key}); + + @override + Widget build(BuildContext context) { + if (!Get.isRegistered()) return const SizedBox.shrink(); + final svc = StakeoutService.to; + + return Obx(() { + // Üres állapot: import-hívás. + if (svc.points.isEmpty) { + return Card( + margin: const EdgeInsets.all(8), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + const Expanded( + child: Text('Nincsenek kitűzési pontok az aktív ' + 'projektben.'), + ), + FilledButton.icon( + onPressed: () => Get.toNamed(Routes.STAKEOUT_IMPORT), + icon: const Icon(Icons.upload_file, size: 18), + label: const Text('Import'), + ), + ], + ), + ), + ); + } + + final t = svc.target.value; + if (t == null) { + return Card( + margin: const EdgeInsets.all(8), + child: Padding( + padding: const EdgeInsets.all(16), + child: Text('Minden pont kitűzve vagy kihagyva. ' + '(${svc.points.length} pont)'), + ), + ); + } + + final within = svc.withinTolerance.value; + final progress = svc.lineProgress[t.lineId]; + + return Card( + margin: const EdgeInsets.all(8), + elevation: 6, + color: within + ? Colors.green.shade50 + : Theme.of(context).colorScheme.surface, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: within + ? const BorderSide(color: Colors.green, width: 2) + : BorderSide.none, + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ── Fejléc ─────────────────────────────────────────── + Row( + children: [ + IconButton( + icon: const Icon(Icons.skip_previous), + visualDensity: VisualDensity.compact, + onPressed: () => svc.advance(backwards: true), + ), + Expanded( + child: Column( + children: [ + Text(t.displayId, + style: const TextStyle( + fontSize: 17, fontWeight: FontWeight.w700)), + if (progress != null) + Text( + '${progress.staked}/${progress.total} kitűzve' + '${t.isOffset ? ' · ELTOLT PONT' : ''}', + style: TextStyle( + fontSize: 11, color: Colors.grey.shade600), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.skip_next), + visualDensity: VisualDensity.compact, + onPressed: () => svc.advance(), + ), + PopupMenuButton( + icon: Icon(switch (svc.orderMode.value) { + StakeoutOrder.idAsc => Icons.trending_up, + StakeoutOrder.idDesc => Icons.trending_down, + StakeoutOrder.nearest => Icons.near_me, + }), + tooltip: 'Sorrend', + onSelected: (m) => svc.orderMode.value = m, + itemBuilder: (_) => const [ + PopupMenuItem( + value: StakeoutOrder.idAsc, + child: Text('Állomás növekvő')), + PopupMenuItem( + value: StakeoutOrder.idDesc, + child: Text('Állomás csökkenő')), + PopupMenuItem( + value: StakeoutOrder.nearest, + child: Text('Legközelebbi')), + ], + ), + Obx(() => IconButton( + icon: Icon(svc.hapticsEnabled.value + ? Icons.vibration + : Icons.phonelink_erase), + visualDensity: VisualDensity.compact, + tooltip: 'Haptikus visszajelzés', + onPressed: () => svc.hapticsEnabled.toggle(), + )), + IconButton( + icon: const Icon(Icons.upload_file), + visualDensity: VisualDensity.compact, + tooltip: 'Pontok importja', + onPressed: () => Get.toNamed(Routes.STAKEOUT_IMPORT), + ), + ], + ), + + // ── Fázis-tartalom ─────────────────────────────────── + if (!svc.hasPosition.value) + const Padding( + padding: EdgeInsets.all(10), + child: Text('Várakozás GNSS pozícióra…', + style: TextStyle(color: Colors.orange)), + ) + else if (svc.nearPhase.value) + _NearPhase(svc: svc) + else + _FarPhase(svc: svc), + + const SizedBox(height: 4), + + // ── Gombsor ────────────────────────────────────────── + Row( + children: [ + IconButton( + icon: Icon(svc.nearPhase.value ? Icons.map : Icons.adjust), + tooltip: + svc.nearPhase.value ? 'Térkép-nézet' : 'Céltábla-nézet', + onPressed: svc.togglePhase, + ), + OutlinedButton( + onPressed: svc.skipCurrent, + child: const Text('Kihagy'), + ), + const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () => _offsetDialog(svc), + icon: const Icon(Icons.alt_route, size: 18), + label: const Text('Eltolás'), + ), + const Spacer(), + FilledButton.icon( + onPressed: () => _store(svc), + style: within + ? FilledButton.styleFrom(backgroundColor: Colors.green) + : null, + icon: const Icon(Icons.push_pin), + label: const Text('Tárol'), + ), + ], + ), + ], + ), + ), + ); + }); + } + + // ═════════════════════════════════════════════════════════════════ + // Tárolás — figyelmeztetés, ha nem RTK fixed (de NEM tiltás) + // ═════════════════════════════════════════════════════════════════ + + Future _store(StakeoutService svc) async { + if (svc.target.value == null || !svc.hasPosition.value) return; + + final quality = GnssService.to.gpsQuality.value; + if (quality != 4) { + final proceed = await Get.dialog(AlertDialog( + icon: const Icon(Icons.warning_amber, color: Colors.orange), + title: const Text('Nincs RTK fixed'), + content: Text( + 'A jelenlegi megoldás: ${_fixLabel(quality)}.\n' + 'A pont tárolható, de a pontosság csökkent lehet — a ' + 'fix-minőség bekerül a jegyzőkönyvbe.', + ), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text('Mégse')), + FilledButton( + onPressed: () => Get.back(result: true), + child: const Text('Tárolás így is')), + ], + )); + if (proceed != true) return; + } + + final stored = await svc.storeCurrent(); + if (stored == null) return; + + Get.snackbar( + '✓ ${stored.displayId} kitűzve', + 'inline ${_cm(stored.devInline)} · crossline ${_cm(stored.devCrossline)}' + '${stored.devDz != null ? ' · dZ ${_cm(stored.devDz)}' : ''}' + ' · ${_fixLabel(stored.fixQuality ?? 0)}', + snackPosition: SnackPosition.BOTTOM, + backgroundColor: const Color(0xFF2E7D32), + colorText: const Color(0xFFFFFFFF), + duration: const Duration(seconds: 3), + ); + + svc.advance(); + } + + Future _offsetDialog(StakeoutService svc) async { + final distCtrl = TextEditingController(text: '2'); + final toRight = true.obs; + + await Get.dialog(AlertDialog( + title: const Text('Eltolt pont (transzverzális)'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'A vonalra merőlegesen, a tervponttól mért távolságra. ' + 'Az eltolás iránya és nagysága bekerül a jegyzőkönyvbe.', + style: TextStyle(fontSize: 12), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + children: [ + for (final d in ['0.5', '1', '2', '5']) + ActionChip( + label: Text('$d m'), onPressed: () => distCtrl.text = d), + ], + ), + const SizedBox(height: 8), + TextField( + controller: distCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: + const InputDecoration(labelText: 'Távolság (m)', isDense: true), + ), + const SizedBox(height: 8), + Obx(() => SegmentedButton( + segments: const [ + ButtonSegment( + value: false, + label: Text('Balra'), + icon: Icon(Icons.west)), + ButtonSegment( + value: true, + label: Text('Jobbra'), + icon: Icon(Icons.east)), + ], + selected: {toRight.value}, + onSelectionChanged: (s) => toRight.value = s.first, + )), + ], + ), + actions: [ + TextButton(onPressed: Get.back, child: const Text('Mégse')), + FilledButton( + onPressed: () async { + final d = double.tryParse(distCtrl.text.replaceAll(',', '.')); + if (d == null || d <= 0) return; + Get.back(); + final p = await svc.createOffset(dist: d, toRight: toRight.value); + if (p != null) { + Get.snackbar( + 'Eltolt pont létrehozva', + '${p.displayId} · ${d.toStringAsFixed(2)} m ' + '${toRight.value ? 'jobbra' : 'balra'} — ez az új cél.', + snackPosition: SnackPosition.BOTTOM); + } + }, + child: const Text('Létrehozás'), + ), + ], + )); + } + + static String _cm(double? v) => v == null + ? '—' + : v.abs() < 1 + ? '${(v * 100).toStringAsFixed(0)} cm' + : '${v.toStringAsFixed(2)} m'; + + static String _fixLabel(int q) => switch (q) { + 4 => 'RTK FIXED', + 5 => 'RTK FLOAT', + 2 => 'DGPS', + 1 => 'GPS', + 0 => 'nincs fix', + _ => 'fix: $q', + }; +} + +// ═══════════════════════════════════════════════════════════════════ +// Távoli fázis: nagy nyíl + távolság +// ═══════════════════════════════════════════════════════════════════ + +class _FarPhase extends StatelessWidget { + final StakeoutService svc; + const _FarPhase({required this.svc}); + + static const _compass = ['É', 'ÉK', 'K', 'DK', 'D', 'DNy', 'Ny', 'ÉNy']; + + @override + Widget build(BuildContext context) { + return Obx(() { + final heading = svc.travelHeading.value; + final bearing = svc.bearingToTarget.value; + final compass = _compass[((bearing + 22.5) % 360 ~/ 45)]; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // A nyíl a HALADÁSI IRÁNYHOZ képest forog; álló helyzetben + // iránytű-ikon + égtáj-szöveg. + SizedBox( + width: 64, + height: 64, + child: heading != null + ? Transform.rotate( + angle: (bearing - heading) * math.pi / 180, + child: const Icon(Icons.navigation, + size: 58, color: Colors.blue), + ) + : const Icon(Icons.explore, size: 52, color: Colors.blueGrey), + ), + const SizedBox(width: 16), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _fmtDist(svc.distance.value), + style: const TextStyle( + fontSize: 30, + fontWeight: FontWeight.w800, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + Text( + heading != null + ? '$compass felé (${bearing.toStringAsFixed(0)}°)' + : 'Indulj el — a nyíl követi a haladásod · $compass', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + if (svc.devDz.value != null) + Text( + 'dZ: ${svc.devDz.value! >= 0 ? 'fel' : 'le'} ' + '${_fmtDist(svc.devDz.value!.abs())}', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + ], + ), + ], + ), + ); + }); + } + + static String _fmtDist(double d) => d >= 1000 + ? '${(d / 1000).toStringAsFixed(2)} km' + : d >= 10 + ? '${d.toStringAsFixed(1)} m' + : '${d.toStringAsFixed(2)} m'; +} + +// ═══════════════════════════════════════════════════════════════════ +// Közeli fázis: céltábla + cm-es eltérések +// ═══════════════════════════════════════════════════════════════════ + +class _NearPhase extends StatelessWidget { + final StakeoutService svc; + const _NearPhase({required this.svc}); + + @override + Widget build(BuildContext context) { + return Obx(() { + final fwd = svc.devForward.value; + final right = svc.devRight.value; + final dz = svc.devDz.value; + final within = svc.withinTolerance.value; + final ne = svc.deviationMode.value == DeviationMode.northEast; + + final (fwdLabel, rightLabel) = switch (svc.deviationMode.value) { + DeviationMode.line => ('Inline', 'Crossline'), + DeviationMode.travel => ('Előre/hátra', 'Jobbra/balra'), + DeviationMode.northEast => ('Észak', 'Kelet'), + }; + + return Column( + children: [ + SegmentedButton( + segments: [ + for (final m in DeviationMode.values) + ButtonSegment( + value: m, + label: Text(m.label, style: const TextStyle(fontSize: 10))), + ], + selected: {svc.deviationMode.value}, + onSelectionChanged: (s) => svc.deviationMode.value = s.first, + showSelectedIcon: false, + style: const ButtonStyle( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap), + ), + const SizedBox(height: 6), + Row( + children: [ + SizedBox( + width: 140, + height: 140, + child: CustomPaint( + painter: _BullseyePainter( + devForward: fwd, + devRight: right, + tolerance: svc.toleranceXY.value, + within: within, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _DevRow( + label: fwdLabel, + value: fwd, + posText: ne ? 'É' : 'előre', + negText: ne ? 'D' : 'hátra'), + _DevRow( + label: rightLabel, + value: right, + posText: ne ? 'K' : 'jobbra', + negText: ne ? 'Ny' : 'balra'), + if (dz != null) + _DevRow( + label: 'Magasság', + value: dz, + posText: 'fel', + negText: 'le'), + const SizedBox(height: 4), + Text( + within + ? '✓ Tűrésen belül ' + '(${(svc.toleranceXY.value * 100).toStringAsFixed(0)} cm)' + : 'Távolság: ' + '${(svc.distance.value * 100).toStringAsFixed(0)} cm', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: within ? Colors.green : null, + ), + ), + ], + ), + ), + ], + ), + ], + ); + }); + } +} + +class _DevRow extends StatelessWidget { + final String label; + final double value; + final String posText; + final String negText; + const _DevRow( + {required this.label, + required this.value, + required this.posText, + required this.negText}); + + @override + Widget build(BuildContext context) { + final cm = value.abs() * 100; + final txt = cm < 100 + ? '${cm.toStringAsFixed(0)} cm' + : '${value.abs().toStringAsFixed(2)} m'; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + SizedBox( + width: 78, + child: Text(label, + style: TextStyle(fontSize: 11, color: Colors.grey.shade600)), + ), + Text( + '${value >= 0 ? posText : negText} $txt', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + fontFeatures: [FontFeature.tabularFigures()], + ), + ), + ], + ), + ); + } +} + +/// Céltábla: középen a CÉL, a kék pötty a MI pozíciónk a célhoz képest. +/// A "felfelé" tengely az aktuális eltérés-mód "előre" iránya. +class _BullseyePainter extends CustomPainter { + final double devForward; + final double devRight; + final double tolerance; + final bool within; + + _BullseyePainter({ + required this.devForward, + required this.devRight, + required this.tolerance, + required this.within, + }); + + @override + void paint(Canvas canvas, Size size) { + final c = Offset(size.width / 2, size.height / 2); + final maxR = size.width / 2 - 4; + const viewRadiusM = 1.2; + final scale = maxR / viewRadiusM; + + final ring = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 1 + ..color = Colors.grey.shade400; + + for (final r in [1.0, 0.5]) { + canvas.drawCircle(c, r * scale, ring); + } + canvas.drawCircle( + c, + tolerance * scale, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..color = within ? Colors.green : Colors.grey.shade600); + + canvas.drawLine(Offset(c.dx - maxR, c.dy), Offset(c.dx + maxR, c.dy), ring); + canvas.drawLine(Offset(c.dx, c.dy - maxR), Offset(c.dx, c.dy + maxR), ring); + + canvas.drawCircle( + c, 4, Paint()..color = within ? Colors.green : Colors.red); + + // A MI pozíciónk a célhoz képest: offset = −(eltérés) az + // (előre, jobbra) bázisban; képernyő-y felfelé = előre. + var px = -devRight * scale; + var py = devForward * scale; + final d = math.sqrt(px * px + py * py); + if (d > maxR) { + px = px / d * maxR; + py = py / d * maxR; + } + final me = c + Offset(px, py); + canvas.drawCircle(me, 7, Paint()..color = Colors.blue); + canvas.drawCircle( + me, + 7, + Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 2 + ..color = Colors.white); + } + + @override + bool shouldRepaint(_BullseyePainter old) => + old.devForward != devForward || + old.devRight != devRight || + old.within != within || + old.tolerance != tolerance; +} diff --git a/lib/pages/stakeout_import/presentation/views/stakeout_import_view.dart b/lib/pages/stakeout_import/presentation/views/stakeout_import_view.dart new file mode 100644 index 0000000..e01d7ad --- /dev/null +++ b/lib/pages/stakeout_import/presentation/views/stakeout_import_view.dart @@ -0,0 +1,402 @@ +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/stakeout_point.dart'; +import 'package:terepi_seged/services/app_database.dart'; +import 'package:terepi_seged/services/coord_converter_service.dart'; +import 'package:terepi_seged/services/project_service.dart'; +import 'package:terepi_seged/services/stakeout_import_service.dart'; +import 'package:terepi_seged/services/stakeout_service.dart'; + +/// Kitűzési pontok importja (CSV / GeoJSON) — felismerés + előnézet. +/// +/// Folyamat: fájlválasztás → automatikus elemzés (elválasztó, tizedesjel, +/// fejléc, oszlopszerepek, koordináta-rendszer) → ELŐNÉZET: minta-táblázat +/// oszloponkénti szerep-választóval + mini-térkép vizuális ellenőrzéshez → +/// import az aktív projektbe. Soha nem importálunk vakon. +class StakeoutImportView extends StatefulWidget { + const StakeoutImportView({super.key}); + + @override + State createState() => _StakeoutImportViewState(); +} + +class _StakeoutImportViewState extends State { + CsvPreview? _preview; + List _roles = []; + String? _error; + bool _busy = false; + + Future _pickFile() async { + setState(() { + _error = null; + _busy = true; + }); + try { +// FileType.any: a Google Drive (és más felhő-providerek) a tárolt + // MIME-típus alapján szűrnek — a .geojson-nak nincs regisztrált + // MIME-je, a Drive a csv/txt fájlokat is gyakran más MIME-mal + // tartja nyilván, ezért custom szűrővel szürkék maradnának. + // A kiterjesztést a kiválasztás UTÁN mi ellenőrizzük. + final result = await FilePicker.platform.pickFiles(type: FileType.any); + final picked = result?.files.single; + final path = picked?.path; + if (path == null) return; + + final name = picked!.name.toLowerCase(); + const allowed = ['.csv', '.txt', '.geojson', '.json']; + if (!allowed.any(name.endsWith)) { + setState(() => _error = 'Nem támogatott fájltípus: ${picked.name} — ' + 'CSV, TXT vagy GeoJSON fájlt válassz.'); + return; + } + + final file = File(path); + final isGeojson = name.endsWith('.json') || name.endsWith('.geojson'); + final preview = isGeojson + ? await StakeoutImportService.analyzeGeojson(file) + : await StakeoutImportService.analyzeCsv(file); + + setState(() { + _preview = preview; + _roles = List.of(preview.guessedRoles); + }); + } catch (e) { + setState(() => _error = e.toString()); + } finally { + setState(() => _busy = false); + } + } + + /// Az aktuális szerep-kiosztással felépített pontok (a mini-térképhez + /// és az importhoz ugyanaz a kód fut — amit látsz, azt kapod). + ({List points, int skipped})? _build() { + final preview = _preview; + final projectId = ProjectService.to.activeProjectId; + if (preview == null || projectId == null) return null; + if (!Get.isRegistered()) return null; + try { + return StakeoutImportService.buildPoints( + preview: preview, + roles: _roles, + projectId: projectId, + ); + } catch (_) { + return null; + } + } + + Future _import() async { + final built = _build(); + if (built == null || built.points.isEmpty) { + Get.snackbar( + 'Import', + 'Nincs importálható pont — ellenőrizd az ' + 'oszlop-megfeleltetést.', + snackPosition: SnackPosition.BOTTOM); + return; + } + setState(() => _busy = true); + try { + final inserted = + await AppDatabase.instance.insertStakeoutPoints(built.points); + final dup = built.points.length - inserted; + if (Get.isRegistered()) { + await StakeoutService.to.load(); + } + Get.back(); + Get.snackbar( + 'Import kész', + '$inserted pont importálva' + '${dup > 0 ? ' · $dup már létező kihagyva' : ''}' + '${built.skipped > 0 ? ' · ${built.skipped} hibás sor' : ''}', + snackPosition: SnackPosition.BOTTOM, + ); + } finally { + setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) { + final preview = _preview; + + return Scaffold( + appBar: AppBar(title: const Text('Kitűzési pontok importja')), + body: _busy && preview == null + ? const Center(child: CircularProgressIndicator()) + : preview == null + ? _EmptyState(onPick: _pickFile, error: _error) + : _buildPreview(context, preview), + bottomNavigationBar: preview == null + ? null + : SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 12), + child: Row( + children: [ + OutlinedButton( + onPressed: _busy ? null : _pickFile, + child: const Text('Másik fájl'), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.icon( + onPressed: _busy ? null : _import, + icon: _busy + ? const SizedBox( + width: 18, + height: 18, + child: + CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.download_done), + label: const Text('Import az aktív projektbe'), + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildPreview(BuildContext context, CsvPreview preview) { + final built = _build(); + final crsLabel = + _roles.contains(ColumnRole.eovY) && _roles.contains(ColumnRole.eovX) + ? 'EOV' + : _roles.contains(ColumnRole.lat) && _roles.contains(ColumnRole.lon) + ? 'WGS84' + : 'nincs koordináta kijelölve!'; + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // ── Összegző chipek ───────────────────────────────────────── + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + Chip( + avatar: const Icon(Icons.description, size: 16), + label: + Text(preview.fileName, style: const TextStyle(fontSize: 12)), + ), + Chip( + label: Text('${preview.rowCount} sor', + style: const TextStyle(fontSize: 12)), + ), + Chip( + avatar: Icon( + crsLabel.startsWith('nincs') + ? Icons.warning_amber + : Icons.public, + size: 16, + color: crsLabel.startsWith('nincs') ? Colors.orange : null, + ), + label: Text('Rendszer: $crsLabel', + style: const TextStyle(fontSize: 12)), + ), + if (built != null) + Chip( + label: Text( + '${built.points.length} érvényes pont' + '${built.skipped > 0 ? ' · ${built.skipped} hibás sor' : ''}', + style: const TextStyle(fontSize: 12), + ), + ), + ], + ), + const SizedBox(height: 12), + + // ── Megfeleltetési táblázat ──────────────────────────────── + Text('Oszlop-megfeleltetés', + style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 4), + Text( + 'Ellenőrizd a felismert szerepeket — az oszlopok fölött ' + 'módosíthatók.', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + const SizedBox(height: 8), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: DataTable( + headingRowHeight: 96, + columnSpacing: 16, + columns: [ + for (var c = 0; c < preview.headers.length; c++) + DataColumn( + label: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(preview.headers[c], + style: const TextStyle( + fontSize: 11, color: Colors.grey)), + DropdownButton( + value: _roles[c], + isDense: true, + style: TextStyle( + fontSize: 12, + fontWeight: _roles[c] == ColumnRole.ignore + ? FontWeight.normal + : FontWeight.w600, + color: _roles[c] == ColumnRole.ignore + ? Colors.grey + : Theme.of(context).colorScheme.primary, + ), + items: [ + for (final r in ColumnRole.values) + DropdownMenuItem(value: r, child: Text(r.label)), + ], + onChanged: (r) { + if (r == null) return; + setState(() { + // Egy szerep csak egy oszlopé lehet. + if (r != ColumnRole.ignore) { + for (var i = 0; i < _roles.length; i++) { + if (_roles[i] == r) { + _roles[i] = ColumnRole.ignore; + } + } + } + _roles[c] = r; + }); + }, + ), + ], + ), + ), + ], + rows: [ + for (final row in preview.sampleRows) + DataRow(cells: [ + for (var c = 0; c < preview.headers.length; c++) + DataCell(Text( + c < row.length ? row[c] : '', + style: const TextStyle(fontSize: 12), + )), + ]), + ], + ), + ), + const SizedBox(height: 16), + + // ── Mini-térkép: vizuális ellenőrzés ─────────────────────── + if (built != null && built.points.isNotEmpty) ...[ + Text('Előnézet a térképen', + style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 4), + Text( + 'Ha a pontok ott vannak, ahol lenniük kell, a megfeleltetés jó.', + style: TextStyle(fontSize: 12, color: Colors.grey.shade600), + ), + const SizedBox(height: 8), + SizedBox( + height: 220, + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: _PreviewMap(points: built.points), + ), + ), + ], + const SizedBox(height: 80), + ], + ); + } +} + +class _PreviewMap extends StatelessWidget { + final List points; + const _PreviewMap({required this.points}); + + @override + Widget build(BuildContext context) { + // Max. 500 markert rajzolunk — előnézetnek bőven elég. + final shown = points.length > 500 + ? [ + for (var i = 0; i < points.length; i += points.length ~/ 500) + points[i] + ] + : points; + + final lats = shown.map((p) => p.planLat); + final lons = shown.map((p) => p.planLon); + final center = LatLng( + (lats.reduce((a, b) => a + b)) / shown.length, + (lons.reduce((a, b) => a + b)) / shown.length, + ); + + return FlutterMap( + options: MapOptions(initialCenter: center, initialZoom: 13), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'hu.appdev.terepi_seged', + ), + MarkerLayer(markers: [ + for (final p in shown) + Marker( + point: LatLng(p.planLat, p.planLon), + width: 10, + height: 10, + child: Container( + decoration: BoxDecoration( + color: Colors.deepOrange, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1), + ), + ), + ), + ]), + ], + ); + } +} + +class _EmptyState extends StatelessWidget { + final VoidCallback onPick; + final String? error; + const _EmptyState({required this.onPick, this.error}); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.upload_file, size: 56, color: Colors.grey.shade400), + const SizedBox(height: 12), + const Text( + 'Válassz CSV vagy GeoJSON fájlt.\n' + 'A program felismeri az elválasztót, a tizedesjelet és az ' + 'oszlopok szerepét (EOV / WGS84), import előtt pedig ' + 'ellenőrizheted az eredményt.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 13), + ), + if (error != null) ...[ + const SizedBox(height: 12), + Text('Hiba: $error', + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 12, color: Colors.red)), + ], + const SizedBox(height: 16), + FilledButton.icon( + onPressed: onPick, + icon: const Icon(Icons.folder_open), + label: const Text('Fájl kiválasztása'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/routes/app_pages.dart b/lib/routes/app_pages.dart index b88afa6..b5e6178 100644 --- a/lib/routes/app_pages.dart +++ b/lib/routes/app_pages.dart @@ -21,6 +21,7 @@ import 'package:terepi_seged/pages/rtcm_test/presentation/views/rtcm_test_view.d import 'package:terepi_seged/pages/settings/presentation/views/settings_view.dart'; import 'package:terepi_seged/pages/socket_test/bindings/socket_test_bindings.dart'; import 'package:terepi_seged/pages/socket_test/presentation/views/socket_test_view.dart'; +import 'package:terepi_seged/pages/stakeout_import/presentation/views/stakeout_import_view.dart'; 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'; @@ -101,6 +102,8 @@ class AppPages { name: Routes.TRACKING, binding: TrackingBinding(), page: () => const TrackingView()), - GetPage(name: Routes.SETTINGS, page: () => const SettingsView()) + GetPage(name: Routes.SETTINGS, page: () => const SettingsView()), + GetPage( + name: Routes.STAKEOUT_IMPORT, page: () => const StakeoutImportView()) ]; } diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart index 8c4a270..9c4de33 100644 --- a/lib/routes/app_routes.dart +++ b/lib/routes/app_routes.dart @@ -24,4 +24,5 @@ abstract class Routes { static const SHELL = '/shell'; static const SETTINGS = '/settings'; + static const STAKEOUT_IMPORT = '/stakeout_import'; } diff --git a/lib/services/app_database.dart b/lib/services/app_database.dart index 32f05be..8d670be 100644 --- a/lib/services/app_database.dart +++ b/lib/services/app_database.dart @@ -9,7 +9,9 @@ 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/stakeout_point.dart'; import 'package:terepi_seged/models/track.dart'; +import 'package:terepi_seged/services/device_identity_service.dart'; import 'package:uuid/uuid.dart'; import '../models/project.dart'; @@ -38,7 +40,7 @@ class AppDatabase { final path = p.join(dbDir.path, 'terepi_seged.db'); return openDatabase(path, - version: 2, + version: 3, onConfigure: (db) => db.execute('PRAGMA foreign_keys = ON'), onCreate: _onCreate, onUpgrade: _onUpgrade); @@ -214,6 +216,8 @@ class AppDatabase { await db.execute( 'CREATE INDEX idx_imp_layers_project ON imported_layers(project_id)'); + await _createStakeoutTable(db); + // Alap projekt létrehozása az első indításhoz final now = DateTime.now().toIso8601String(); await db.insert('projects', { @@ -239,6 +243,9 @@ class AppDatabase { ALTER TABLE imported_layers ADD COLUMN stroke_width REAL; '''); } + if (oldVersion < 3) { + await _createStakeoutTable(db); + } } // ── Projects CRUD ───────────────────────────────────────────────── @@ -670,4 +677,114 @@ class AppDatabase { ); return rows.map(MeasuredPoint.fromMap).toList(); } + + // ═══════════════════════════════════════════════════════════════ + // KITŰZÉS (stakeout_points) — szeizmikus line/station modell + // ═══════════════════════════════════════════════════════════════ + + Future _createStakeoutTable(Database db) async { + await db.execute(''' + CREATE TABLE IF NOT EXISTS stakeout_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, + name TEXT NOT NULL, + point_type TEXT NOT NULL DEFAULT 'geofon', + source TEXT NOT NULL DEFAULT 'csv', + plan_eov_y REAL NOT NULL, + plan_eov_x REAL NOT NULL, + plan_eov_z REAL, + plan_lat REAL NOT NULL, + plan_lon REAL NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + measured_eov_y REAL, + measured_eov_x REAL, + measured_eov_z REAL, + measured_lat REAL, + measured_lon REAL, + dev_inline REAL, + dev_crossline REAL, + dev_dz REAL, + fix_quality INTEGER, + accuracy REAL, + tilt_deg REAL, + staked_at TEXT, + is_offset INTEGER NOT NULL DEFAULT 0, + parent_uuid TEXT, + offset_dist REAL, + offset_bearing REAL, + created_by TEXT, + device_id TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + deleted_at TEXT, + sync_status TEXT NOT NULL DEFAULT 'pending' + ) + '''); + await db.execute('CREATE INDEX IF NOT EXISTS idx_sp_proj_line ' + 'ON stakeout_points(project_id, line_id, station)'); + await db.execute('CREATE INDEX IF NOT EXISTS idx_sp_status ' + 'ON stakeout_points(status)'); + await db.execute('CREATE INDEX IF NOT EXISTS idx_sp_sync ' + 'ON stakeout_points(sync_status)'); + } + + Future insertStakeoutPoint(StakeoutPoint p) async { + final db = await database; + final map = p.toMap(); + map['device_id'] ??= DeviceIdentityService.to.deviceId; + return db.insert('stakeout_points', map); + } + + /// Tömeges beszúrás importhoz — tranzakcióban; az azonos + /// (projekt, vonal, station) sorokat kihagyja. Visszaadja a + /// ténylegesen beszúrt darabszámot. + Future insertStakeoutPoints(List points) async { + final db = await database; + var inserted = 0; + await db.transaction((txn) async { + for (final p in points) { + final dup = await txn.query('stakeout_points', + columns: ['id'], + where: 'project_id = ? AND line_id = ? AND station = ? ' + 'AND deleted_at IS NULL', + whereArgs: [p.projectId, p.lineId, p.station], + limit: 1); + if (dup.isNotEmpty) continue; + final map = p.toMap(); + map['device_id'] ??= DeviceIdentityService.to.deviceId; + await txn.insert('stakeout_points', map); + inserted++; + } + }); + return inserted; + } + + Future updateStakeoutPoint(StakeoutPoint p) async { + final db = await database; + final map = p.toMap() + ..['updated_at'] = DateTime.now().toIso8601String() + ..['sync_status'] = 'pending'; + await db.update('stakeout_points', map, where: 'id = ?', whereArgs: [p.id]); + } + + /// Soft delete — a törlés is szinkronizálható lesz (4. ütem). + Future softDeleteStakeoutPoint(int id) async { + final db = await database; + final now = DateTime.now().toIso8601String(); + await db.update('stakeout_points', + {'deleted_at': now, 'updated_at': now, 'sync_status': 'pending'}, + where: 'id = ?', whereArgs: [id]); + } + + Future> listStakeoutPoints(int projectId) async { + final db = await database; + final rows = await db.query('stakeout_points', + where: 'project_id = ? AND deleted_at IS NULL', + whereArgs: [projectId], + orderBy: 'line_id ASC, station ASC'); + return rows.map(StakeoutPoint.fromMap).toList(); + } } diff --git a/lib/services/stakeout_import_service.dart b/lib/services/stakeout_import_service.dart new file mode 100644 index 0000000..0e5f487 --- /dev/null +++ b/lib/services/stakeout_import_service.dart @@ -0,0 +1,481 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:terepi_seged/services/coord_converter_service.dart'; + +import '../../models/stakeout_point.dart'; + +/// Oszlop-szerepek a CSV-megfeleltetéshez. +enum ColumnRole { + ignore('—'), + station('Állomás / pontszám'), + line('Vonal'), + name('Megnevezés'), + eovY('EOV Y (kelet)'), + eovX('EOV X (észak)'), + lat('Szélesség (lat)'), + lon('Hosszúság (lon)'), + elevation('Magasság'); + + final String label; + const ColumnRole(this.label); +} + +/// Az elemzés eredménye — ebből épül az előnézeti képernyő. +class CsvPreview { + final String fileName; + final String delimiter; + final bool hasHeader; + final bool decimalComma; + final List headers; // fejléc vagy "1. oszlop" ... + final List> sampleRows; // első max. 6 adatsor + final List> allRows; // minden adatsor (nyers) + final List guessedRoles; + final String detectedCrs; // 'EOV' | 'WGS84' | '?' + final int badRowCount; + + CsvPreview({ + required this.fileName, + required this.delimiter, + required this.hasHeader, + required this.decimalComma, + required this.headers, + required this.sampleRows, + required this.allRows, + required this.guessedRoles, + required this.detectedCrs, + required this.badRowCount, + }); + + int get rowCount => allRows.length; +} + +/// CSV / GeoJSON kitűzési pont import — magyar terepi sajátosságokkal: +/// * elválasztó-felismerés (';' a magyar Excel alapértelmezése, ',', tab) +/// * tizedesVESSZŐ kezelése +/// * kódolás: UTF-8, hibánál Latin-1 visszaesés (a koordináták így is +/// hibátlanok, legfeljebb az ő/ű torzulhat a nevekben) +/// * oszlopszerep-felismerés ÉRTÉKTARTOMÁNY alapján — Magyarországon az +/// EOV Y (421–960 ezer), EOV X (48–384 ezer), lat (45,5–48,8) és +/// lon (16–23) tartományok páronként diszjunktak, így a felismerés +/// nagyon megbízható; a fejlécnevek csak megerősítésként számítanak. +class StakeoutImportService { + StakeoutImportService._(); + + // ── Magyar értéktartományok ────────────────────────────────────── + static bool _isEovY(double v) => v >= 421000 && v <= 960000; + static bool _isEovX(double v) => v >= 48000 && v <= 384000; + static bool _isLat(double v) => v >= 45.5 && v <= 48.8; + static bool _isLon(double v) => v >= 16.0 && v <= 23.0; + static bool _isElev(double v) => v >= -50 && v <= 3000; + + // ═════════════════════════════════════════════════════════════════ + // CSV elemzés + // ═════════════════════════════════════════════════════════════════ + + static Future analyzeCsv(File file) async { + final bytes = await file.readAsBytes(); + String text; + try { + text = utf8.decode(bytes); + } catch (_) { + text = latin1.decode(bytes); // Windows-1250 közelítése + } + // BOM eltávolítás + if (text.isNotEmpty && text.codeUnitAt(0) == 0xFEFF) { + text = text.substring(1); + } + + final lines = text + .split(RegExp(r'\r\n|\r|\n')) + .where((l) => l.trim().isNotEmpty) + .toList(); + if (lines.isEmpty) { + throw const FormatException('A fájl üres.'); + } + + // 1. Elválasztó: amelyikből a legtöbb van KONZISZTENSEN a sorokban. + final delimiter = _detectDelimiter(lines.take(20).toList()); + + // 2. Sorok felbontása. + var rows = lines.map((l) => _splitLine(l, delimiter)).toList(); + final colCount = rows.map((r) => r.length).reduce((a, b) => a > b ? a : b); + // Rövid sorok kipótlása üres cellákkal, hogy a táblázat téglalap legyen. + rows = rows + .map((r) => [...r, ...List.filled(colCount - r.length, '')]) + .toList(); + + // 3. Tizedesvessző? Ha ';' az elválasztó és sok "123,45" mintájú cella + // van, akkor a vessző tizedesjel. + final decimalComma = _detectDecimalComma(rows.take(30).toList()); + + // 4. Fejléc: az első sor akkor fejléc, ha a cellái NEM számok, de a + // második soréi többségében igen. + final hasHeader = rows.length > 1 && + _numericRatio(rows.first, decimalComma) < 0.5 && + _numericRatio(rows[1], decimalComma) >= 0.5; + + final headers = hasHeader + ? rows.first.map((h) => h.trim()).toList() + : List.generate(colCount, (i) => '${i + 1}. oszlop'); + final dataRows = hasHeader ? rows.sublist(1) : rows; + + // 5. Oszlopszerepek felismerése. + final roles = _guessRoles(headers, dataRows, decimalComma); + + final crs = + roles.contains(ColumnRole.eovY) && roles.contains(ColumnRole.eovX) + ? 'EOV' + : roles.contains(ColumnRole.lat) && roles.contains(ColumnRole.lon) + ? 'WGS84' + : '?'; + + return CsvPreview( + fileName: file.uri.pathSegments.last, + delimiter: delimiter, + hasHeader: hasHeader, + decimalComma: decimalComma, + headers: headers, + sampleRows: dataRows.take(6).toList(), + allRows: dataRows, + guessedRoles: roles, + detectedCrs: crs, + badRowCount: 0, // a tényleges építéskor derül ki + ); + } + + static String _detectDelimiter(List lines) { + var best = ';'; + var bestScore = -1; + for (final d in [';', ',', '\t']) { + final counts = lines.map((l) => d.allMatches(l).length).toList(); + final min = counts.reduce((a, b) => a < b ? a : b); + // Pontszám: minden sorban legyen legalább 1, és konzisztens legyen. + final score = min > 0 ? min * 10 - (counts.toSet().length - 1) : -1; + if (score > bestScore) { + bestScore = score; + best = d; + } + } + return best; + } + + /// Egyszerű felbontás idézőjel-kezeléssel ("a;b" egyben marad). + static List _splitLine(String line, String delimiter) { + final cells = []; + final sb = StringBuffer(); + var inQuotes = false; + for (var i = 0; i < line.length; i++) { + final c = line[i]; + if (c == '"') { + inQuotes = !inQuotes; + } else if (c == delimiter && !inQuotes) { + cells.add(sb.toString().trim()); + sb.clear(); + } else { + sb.write(c); + } + } + cells.add(sb.toString().trim()); + return cells; + } + + static final _decimalCommaRe = RegExp(r'^-?\d+,\d+$'); + static final _decimalDotRe = RegExp(r'^-?\d+\.\d+$'); + + static bool _detectDecimalComma(List> rows) { + var comma = 0, dot = 0; + for (final row in rows) { + for (final cell in row) { + if (_decimalCommaRe.hasMatch(cell)) comma++; + if (_decimalDotRe.hasMatch(cell)) dot++; + } + } + return comma > dot; + } + + /// Cella → szám, a tizedesjel-beállítás figyelembevételével. + static double? parseNum(String cell, bool decimalComma) { + var s = cell.trim(); + if (s.isEmpty) return null; + if (decimalComma) { + s = s.replaceAll(' ', '').replaceAll(',', '.'); + } + return double.tryParse(s); + } + + static double _numericRatio(List row, bool decimalComma) { + if (row.isEmpty) return 0; + final n = row.where((c) => parseNum(c, decimalComma) != null).length; + return n / row.length; + } + + // ── Szerepfelismerés ───────────────────────────────────────────── + + static List _guessRoles( + List headers, List> rows, bool decimalComma) { + final n = headers.length; + final sample = rows.take(200).toList(); + final roles = List.filled(n, ColumnRole.ignore); + + // Oszloponkénti statisztika. + final stats = List.generate(n, (c) { + final values = []; + final raw = []; + for (final row in sample) { + if (c >= row.length || row[c].isEmpty) continue; + raw.add(row[c]); + final v = parseNum(row[c], decimalComma); + if (v != null) values.add(v); + } + final distinct = raw.toSet().length; + final allInt = + values.isNotEmpty && values.every((v) => v == v.roundToDouble()); + double share(bool Function(double) test) => + values.isEmpty ? 0 : values.where(test).length / values.length; + return ( + numericRatio: raw.isEmpty ? 0.0 : values.length / raw.length, + values: values, + distinct: distinct, + count: raw.length, + allInt: allInt, + eovY: share(_isEovY), + eovX: share(_isEovX), + lat: share(_isLat), + lon: share(_isLon), + elev: share(_isElev), + ); + }); + + String h(int c) => headers[c].toLowerCase(); + bool hHas(int c, List keys) => keys.any((k) => h(c).contains(k)); + + // 1. Koordináták — tartomány alapján (95% feletti találat kell), + // fejlécnév csak döntetlennél számít. + int pick(double Function(int) score, List headerKeys) { + var best = -1; + var bestScore = 0.94; + for (var c = 0; c < n; c++) { + if (roles[c] != ColumnRole.ignore) continue; + var s = score(c); + if (s > 0.94 && hHas(c, headerKeys)) s += 0.05; + if (s > bestScore) { + bestScore = s; + best = c; + } + } + return best; + } + + final cEovY = pick((c) => stats[c].eovY, ['y', 'kelet', 'east']); + if (cEovY >= 0) roles[cEovY] = ColumnRole.eovY; + final cEovX = pick((c) => stats[c].eovX, ['x', 'észak', 'eszak', 'north']); + if (cEovX >= 0) roles[cEovX] = ColumnRole.eovX; + final cLat = pick((c) => stats[c].lat, ['lat', 'fi', 'szél', 'szel']); + if (cLat >= 0) roles[cLat] = ColumnRole.lat; + final cLon = pick((c) => stats[c].lon, ['lon', 'lambda', 'hossz']); + if (cLon >= 0) roles[cLon] = ColumnRole.lon; + + // 2. Magasság: numerikus, elfogadható tartomány, NEM egész-azonosító + // jellegű; fejléc segít. + for (var c = 0; c < n; c++) { + if (roles[c] != ColumnRole.ignore) continue; + final s = stats[c]; + if (s.numericRatio > 0.9 && + s.elev > 0.94 && + (hHas(c, ['z', 'mag', 'elev', 'h']) || !s.allInt)) { + roles[c] = ColumnRole.elevation; + break; + } + } + + // 3. Állomás/pontszám: egész, (közel) egyedi értékek. + var bestStation = -1; + var bestUnique = 0.9; + for (var c = 0; c < n; c++) { + if (roles[c] != ColumnRole.ignore) continue; + final s = stats[c]; + if (s.numericRatio > 0.95 && s.allInt && s.count > 0) { + var unique = s.distinct / s.count; + if (hHas(c, ['psz', 'pont', 'station', 'áll', 'all', 'id'])) { + unique += 0.05; + } + if (unique > bestUnique) { + bestUnique = unique; + bestStation = c; + } + } + } + if (bestStation >= 0) roles[bestStation] = ColumnRole.station; + + // 4. Vonal: kevés egyedi értékű oszlop (a pontszám sokszorosa tartozik + // egy vonalhoz); fejléc segít. + for (var c = 0; c < n; c++) { + if (roles[c] != ColumnRole.ignore) continue; + final s = stats[c]; + final lowCardinality = + s.count >= 10 && s.distinct <= (s.count / 4).ceil(); + if (hHas(c, ['line', 'vonal', 'ln']) || lowCardinality) { + roles[c] = ColumnRole.line; + break; + } + } + + // 5. Név: az első még szabad, többségében szöveges oszlop. + for (var c = 0; c < n; c++) { + if (roles[c] != ColumnRole.ignore) continue; + if (stats[c].numericRatio < 0.5 && stats[c].count > 0) { + roles[c] = ColumnRole.name; + break; + } + } + + return roles; + } + + // ═════════════════════════════════════════════════════════════════ + // Pontok építése a megfeleltetés alapján + // ═════════════════════════════════════════════════════════════════ + + /// A (kézzel jóváhagyott) szerep-kiosztás alapján felépíti a pontokat. + /// Visszaadja a pontokat és a kihagyott (hibás) sorok számát. + static ({List points, int skipped}) buildPoints({ + required CsvPreview preview, + required List roles, + required int projectId, + }) { + int col(ColumnRole r) => roles.indexOf(r); + final cStation = col(ColumnRole.station); + final cLine = col(ColumnRole.line); + final cName = col(ColumnRole.name); + final cEovY = col(ColumnRole.eovY); + final cEovX = col(ColumnRole.eovX); + final cLat = col(ColumnRole.lat); + final cLon = col(ColumnRole.lon); + final cElev = col(ColumnRole.elevation); + + final isEov = cEovY >= 0 && cEovX >= 0; + if (!isEov && !(cLat >= 0 && cLon >= 0)) { + throw const FormatException( + 'Hiányzó koordináta-oszlopok: EOV Y+X vagy lat+lon kell.'); + } + + final conv = CoordConverterService.to; + final points = []; + var skipped = 0; + var autoStation = 1; + + for (final row in preview.allRows) { + double? get(int c) => c >= 0 && c < row.length + ? parseNum(row[c], preview.decimalComma) + : null; + + double eovY, eovX, lat, lon; + if (isEov) { + final y = get(cEovY), x = get(cEovX); + if (y == null || x == null || !_isEovY(y) || !_isEovX(x)) { + skipped++; + continue; + } + eovY = y; + eovX = x; + final p = conv.eovToWgsPoint(y, x); + lon = p.x; + lat = p.y; + } else { + final la = get(cLat), lo = get(cLon); + if (la == null || lo == null || !_isLat(la) || !_isLon(lo)) { + skipped++; + continue; + } + lat = la; + lon = lo; + final p = conv.wgsToEovPoint(lo, la); + eovY = p.x; + eovX = p.y; + } + + final stationVal = get(cStation); + final station = stationVal?.round() ?? autoStation++; + final line = cLine >= 0 && cLine < row.length ? row[cLine].trim() : ''; + final name = cName >= 0 && cName < row.length && row[cName].isNotEmpty + ? row[cName].trim() + : station.toString(); + + points.add(StakeoutPoint( + projectId: projectId, + lineId: line, + station: station, + name: name, + source: 'csv', + planEovY: eovY, + planEovX: eovX, + planEovZ: get(cElev), + planLat: lat, + planLon: lon, + )); + } + return (points: points, skipped: skipped); + } + + // ═════════════════════════════════════════════════════════════════ + // GeoJSON + // ═════════════════════════════════════════════════════════════════ + + /// GeoJSON → CsvPreview-kompatibilis táblázat: a property-k az oszlopok, + /// plusz a kinyert lon/lat. Így ugyanaz az előnézeti/megfeleltetési + /// képernyő szolgálja ki, mint a CSV-t. + static Future analyzeGeojson(File file) async { + final json = jsonDecode(await file.readAsString()); + final features = (json['features'] as List?) ?? []; + + final propKeys = {}; + final rows = >[]; + for (final f in features) { + final geom = f['geometry']; + if (geom == null || geom['type'] != 'Point') continue; + propKeys.addAll( + ((f['properties'] as Map?) ?? {}).keys.map((k) => k.toString())); + } + final keys = propKeys.toList(); + + for (final f in features) { + final geom = f['geometry']; + if (geom == null || geom['type'] != 'Point') continue; + final coords = geom['coordinates'] as List; + final props = (f['properties'] as Map?) ?? {}; + rows.add([ + for (final k in keys) '${props[k] ?? ''}', + '${coords[0]}', // lon + '${coords[1]}', // lat + if (coords.length > 2) '${coords[2]}' else '', + ]); + } + if (rows.isEmpty) { + throw const FormatException('A GeoJSON nem tartalmaz Point elemet.'); + } + + final headers = [...keys, 'lon', 'lat', 'z']; + final roles = _guessRoles(headers, rows, false); + // A GeoJSON szabvány szerint mindig WGS84 — a koordináta-oszlopokat + // ismerjük, kényszerítjük. + roles[headers.length - 3] = ColumnRole.lon; + roles[headers.length - 2] = ColumnRole.lat; + if (rows.any((r) => r.last.isNotEmpty)) { + roles[headers.length - 1] = ColumnRole.elevation; + } + + return CsvPreview( + fileName: file.uri.pathSegments.last, + delimiter: ',', + hasHeader: true, + decimalComma: false, + headers: headers, + sampleRows: rows.take(6).toList(), + allRows: rows, + guessedRoles: roles, + detectedCrs: 'WGS84', + badRowCount: 0, + ); + } +} diff --git a/lib/services/stakeout_service.dart b/lib/services/stakeout_service.dart new file mode 100644 index 0000000..0895b97 --- /dev/null +++ b/lib/services/stakeout_service.dart @@ -0,0 +1,469 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/services.dart'; +import 'package:get/get.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 '../../models/stakeout_point.dart'; + +/// Kitűzési sorrend. +enum StakeoutOrder { idAsc, idDesc, nearest } + +/// Eltérés-kijelzési mód a közeli fázisban. +enum DeviationMode { + line('Inline / crossline'), + travel('Haladási irány'), + northEast('Észak / kelet'); + + final String label; + const DeviationMode(this.label); +} + +/// A kitűzés "agya": célpont-kezelés, sorrend, vonal-geometria, +/// eltérés-számítás, tárolás, haptikus visszajelzés. +/// +/// Az egyetlen térképnézetbe illeszkedik: a MapSurveyController a +/// setMode()-ban hívja a [setActive]-ot (mode == MapSurveyMode.stakeout), +/// a service pedig csak aktív állapotban dolgozik (haptika, fázis). +/// Minden adatbázisművelet az AppDatabase-en keresztül megy. +/// +/// Geometria: minden számítás EOV-SÍKBAN, méterben. A crossline irány a +/// szomszédos állomások szakaszaiból SZÁMÍTÓDIK — a vonal első pontjánál +/// az első szakasz hátrafelé, az utolsónál az utolsó szakasz előre +/// extrapolálásával, belső töréspontnál a két irány átlagával; virtuális +/// segédpontot nem tárolunk. +class StakeoutService extends GetxService { + static StakeoutService get to => Get.find(); + + AppDatabase get _db => AppDatabase.instance; + + // ── Mód-aktiválás ──────────────────────────────────────────────── + final active = false.obs; + + /// A MapSurveyController hívja módváltáskor. + Future setActive(bool value) async { + if (active.value == value) return; + active.value = value; + if (value) { + await load(); + _applyPhase(); + _scheduleHaptic(); + } else { + _hapticTimer?.cancel(); + } + } + + // ── Állapot ────────────────────────────────────────────────────── + final points = [].obs; + final target = Rxn(); + final orderMode = StakeoutOrder.idAsc.obs; + final deviationMode = DeviationMode.line.obs; + + /// Tűrés (m) — tárolásnál és a céltábla belső körénél. + final toleranceXY = 0.03.obs; + + /// Közeli fázis (céltábla-nézet); automatikus 5 m alatt, kézzel + /// felülbírálható. + final nearPhase = false.obs; + static const nearPhaseDistance = 5.0; + bool? _manualPhase; + + final hapticsEnabled = true.obs; + + // ── Aktuális pozíció (EOV) és navigációs értékek ──────────────── + final hasPosition = false.obs; + final curEovY = 0.0.obs; + final curEovX = 0.0.obs; + final curAlt = 0.0.obs; + + final distance = 0.0.obs; + final bearingToTarget = 0.0.obs; // fok, EOV-észak = 0, óramutató + final travelHeading = Rxn(); // null = állunk + + /// Eltérés az aktuális [deviationMode] szerint: előre(+)/hátra(−), + /// jobbra(+)/balra(−); dz: fel(+)/le(−). + final devForward = 0.0.obs; + final devRight = 0.0.obs; + final devDz = Rxn(); + final withinTolerance = false.obs; + + double? _histY, _histX; + Timer? _hapticTimer; + bool _toleranceAnnounced = false; + + @override + void onInit() { + super.onInit(); + if (Get.isRegistered()) { + ever(GnssService.to.lastGgaLine, (_) => _onPosition()); + } + } + + @override + void onClose() { + _hapticTimer?.cancel(); + super.onClose(); + } + + // ═════════════════════════════════════════════════════════════════ + // Betöltés / cél-kezelés + // ═════════════════════════════════════════════════════════════════ + + Future load() async { + final projectId = ProjectService.to.activeProjectId; + if (projectId == null) { + points.clear(); + target.value = null; + return; + } + points.value = await _db.listStakeoutPoints(projectId); + if (target.value == null || + !points.any((p) => p.uuid == target.value!.uuid)) { + target.value = _firstPending(); + } + _recompute(); + } + + List get lines => + points.map((p) => p.lineId).toSet().toList()..sort(); + + Map get lineProgress { + final m = {}; + for (final p in points) { + final cur = m[p.lineId] ?? (total: 0, staked: 0); + m[p.lineId] = ( + total: cur.total + 1, + staked: cur.staked + (p.status == StakeoutStatus.staked ? 1 : 0), + ); + } + return m; + } + + void setTarget(StakeoutPoint p) { + target.value = p; + _manualPhase = null; // új célnál vissza automatikus fázisra + _toleranceAnnounced = false; + _recompute(); + } + + void togglePhase() { + _manualPhase = !nearPhase.value; + _applyPhase(); + } + + StakeoutPoint? _firstPending() { + final pending = + points.where((p) => p.status == StakeoutStatus.pending).toList(); + if (pending.isEmpty) return null; + pending.sort((a, b) => a.lineId != b.lineId + ? a.lineId.compareTo(b.lineId) + : a.station.compareTo(b.station)); + return orderMode.value == StakeoutOrder.idDesc + ? pending.last + : pending.first; + } + + /// Következő cél a sorrend-mód szerint — a CÉL VONALÁN belül lép, + /// elfogyva a következő vonalra. + StakeoutPoint? nextTarget({bool backwards = false}) { + final cur = target.value; + if (cur == null) return _firstPending(); + final pending = points + .where((p) => p.status == StakeoutStatus.pending && p.uuid != cur.uuid) + .toList(); + if (pending.isEmpty) return null; + + final sameLine = pending.where((p) => p.lineId == cur.lineId).toList() + ..sort((a, b) => a.station.compareTo(b.station)); + + switch (orderMode.value) { + case StakeoutOrder.nearest: + final pool = sameLine.isNotEmpty ? sameLine : pending; + pool.sort((a, b) => _distTo(a).compareTo(_distTo(b))); + return pool.first; + + case StakeoutOrder.idAsc: + case StakeoutOrder.idDesc: + final asc = (orderMode.value == StakeoutOrder.idAsc) != backwards; + final candidates = sameLine.where( + (p) => asc ? p.station > cur.station : p.station < cur.station); + if (candidates.isNotEmpty) { + return asc + ? candidates.reduce((a, b) => a.station < b.station ? a : b) + : candidates.reduce((a, b) => a.station > b.station ? a : b); + } + final others = pending.where((p) => p.lineId != cur.lineId).toList(); + if (others.isEmpty) return null; + others.sort((a, b) => a.lineId != b.lineId + ? a.lineId.compareTo(b.lineId) + : a.station.compareTo(b.station)); + return asc ? others.first : others.last; + } + } + + void advance({bool backwards = false}) { + final n = nextTarget(backwards: backwards); + if (n != null) setTarget(n); + } + + double _distTo(StakeoutPoint p) { + final dy = p.planEovY - curEovY.value; + final dx = p.planEovX - curEovX.value; + return math.sqrt(dy * dy + dx * dx); + } + + // ═════════════════════════════════════════════════════════════════ + // Pozíció + eltéré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); + curEovY.value = p.x; + curEovX.value = p.y; + curAlt.value = gnss.altitude.value; + hasPosition.value = true; + + // Haladási irány: legalább 0,5 m elmozdulásból (állva zajos lenne). + if (_histY != null) { + final dy = curEovY.value - _histY!; + final dx = curEovX.value - _histX!; + if (math.sqrt(dy * dy + dx * dx) >= 0.5) { + travelHeading.value = _bearingDeg(dy, dx); + _histY = curEovY.value; + _histX = curEovX.value; + } + } else { + _histY = curEovY.value; + _histX = curEovX.value; + } + + _recompute(); + } + + void _recompute() { + final t = target.value; + if (t == null || !hasPosition.value) { + withinTolerance.value = false; + return; + } + + final dy = t.planEovY - curEovY.value; + final dx = t.planEovX - curEovX.value; + distance.value = math.sqrt(dy * dy + dx * dx); + bearingToTarget.value = _bearingDeg(dy, dx); + + final double fwdBearing; + switch (deviationMode.value) { + case DeviationMode.line: + fwdBearing = lineBearingAt(t) ?? travelHeading.value ?? 0; + case DeviationMode.travel: + fwdBearing = travelHeading.value ?? 0; + case DeviationMode.northEast: + fwdBearing = 0; + } + + final rad = fwdBearing * math.pi / 180; + devForward.value = dy * math.sin(rad) + dx * math.cos(rad); + devRight.value = dy * math.cos(rad) - dx * math.sin(rad); + devDz.value = t.planEovZ != null ? t.planEovZ! - curAlt.value : null; + + final wasWithin = withinTolerance.value; + withinTolerance.value = distance.value <= toleranceXY.value; + + if (active.value) { + _applyPhase(); + if (withinTolerance.value && !wasWithin && !_toleranceAnnounced) { + _toleranceAnnounced = true; + if (hapticsEnabled.value) HapticFeedback.heavyImpact(); + SystemSound.play(SystemSoundType.alert); + } else if (!withinTolerance.value) { + _toleranceAnnounced = false; + } + _scheduleHaptic(); + } + } + + void _applyPhase() { + nearPhase.value = _manualPhase ?? + (hasPosition.value && + target.value != null && + distance.value <= nearPhaseDistance); + } + + /// Haptikus "geiger": közeledve sűrűsödő pulzus, tűrésen belül gyors. + void _scheduleHaptic() { + _hapticTimer?.cancel(); + if (!active.value || + !hapticsEnabled.value || + !hasPosition.value || + target.value == null) { + return; + } + final d = distance.value; + if (d > 30) return; + + final ms = withinTolerance.value + ? 150 + : d <= 1 + ? 250 + : d <= 3 + ? 450 + : d <= 10 + ? 800 + : 1500; + + _hapticTimer = Timer(Duration(milliseconds: ms), () { + if (withinTolerance.value) { + HapticFeedback.mediumImpact(); + } else { + HapticFeedback.lightImpact(); + } + _scheduleHaptic(); + }); + } + + static double _bearingDeg(double dy, double dx) { + final b = math.atan2(dy, dx) * 180 / math.pi; + return (b + 360) % 360; + } + + // ── Vonal-geometria ────────────────────────────────────────────── + + List _linePoints(String lineId) { + final lp = points.where((p) => p.lineId == lineId && !p.isOffset).toList() + ..sort((a, b) => a.station.compareTo(b.station)); + return lp; + } + + /// A vonal iránya (fok) az adott pontnál, a station-növekedés felé. + double? lineBearingAt(StakeoutPoint p) { + final lp = _linePoints(p.lineId); + if (lp.length < 2) return null; + var idx = lp.indexWhere((e) => e.uuid == p.uuid); + if (idx < 0) { + // Eltolt pont: a legközelebbi vonalpont szerint. + var bestD = double.infinity; + for (var k = 0; k < lp.length; k++) { + final d = math.pow(lp[k].planEovY - p.planEovY, 2) + + math.pow(lp[k].planEovX - p.planEovX, 2); + if (d < bestD) { + bestD = d.toDouble(); + idx = k; + } + } + } + + double segBearing(StakeoutPoint a, StakeoutPoint b) => + _bearingDeg(b.planEovY - a.planEovY, b.planEovX - a.planEovX); + + if (idx == 0) return segBearing(lp[0], lp[1]); + if (idx == lp.length - 1) return segBearing(lp[idx - 1], lp[idx]); + final b1 = segBearing(lp[idx - 1], lp[idx]) * math.pi / 180; + final b2 = segBearing(lp[idx], lp[idx + 1]) * math.pi / 180; + return _bearingDeg( + math.sin(b1) + math.sin(b2), math.cos(b1) + math.cos(b2)); + } + + // ═════════════════════════════════════════════════════════════════ + // Műveletek (adatbázis: AppDatabase) + // ═════════════════════════════════════════════════════════════════ + + /// Tárolás a jelenlegi mért pozícióval. A rekordba az eltérés mindig + /// vonal-relatívan (inline/crossline) kerül; vonal híján É/K bontásban. + Future storeCurrent() async { + final t = target.value; + if (t == null || !hasPosition.value) return null; + final gnss = GnssService.to; + + final dy = t.planEovY - curEovY.value; + final dx = t.planEovX - curEovX.value; + final lineBearing = lineBearingAt(t); + final rad = (lineBearing ?? 0) * math.pi / 180; + final inline = + lineBearing != null ? dy * math.sin(rad) + dx * math.cos(rad) : dx; + final crossline = + lineBearing != null ? dy * math.cos(rad) - dx * math.sin(rad) : dy; + + final updated = t.copyWith( + status: StakeoutStatus.staked, + measuredEovY: curEovY.value, + measuredEovX: curEovX.value, + measuredEovZ: curAlt.value, + measuredLat: gnss.latitude.value, + measuredLon: gnss.longitude.value, + devInline: inline, + devCrossline: crossline, + devDz: t.planEovZ != null ? t.planEovZ! - curAlt.value : null, + fixQuality: gnss.gpsQuality.value, + accuracy: gnss.horizontalAccuracy, + stakedAt: DateTime.now(), + ); + await _db.updateStakeoutPoint(updated); + final i = points.indexWhere((p) => p.uuid == t.uuid); + if (i >= 0) points[i] = updated; + points.refresh(); + return updated; + } + + Future skipCurrent() async { + final t = target.value; + if (t == null) return; + final updated = t.copyWith(status: StakeoutStatus.skipped); + await _db.updateStakeoutPoint(updated); + final i = points.indexWhere((p) => p.uuid == t.uuid); + if (i >= 0) points[i] = updated; + points.refresh(); + advance(); + } + + /// Transzverzális eltolt pont: a vonalra merőlegesen [dist] méterre + /// ([toRight] = jobbra a station-növekedés irányából nézve). Az új + /// pont lesz a cél; az eltolás-vektor a rekordba kerül. + Future createOffset( + {required double dist, required bool toRight}) async { + final t = target.value; + if (t == null) return null; + + final base = lineBearingAt(t) ?? travelHeading.value ?? 0; + final bearing = (base + (toRight ? 90 : -90) + 360) % 360; + final rad = bearing * math.pi / 180; + final eovY = t.planEovY + dist * math.sin(rad); + final eovX = t.planEovX + dist * math.cos(rad); + final w = CoordConverterService.to.eovToWgsPoint(eovY, eovX); + + final offset = StakeoutPoint( + projectId: t.projectId, + lineId: t.lineId, + station: t.station, + name: '${t.name}/E', + pointType: t.pointType, + source: 'offset', + planEovY: eovY, + planEovX: eovX, + planEovZ: t.planEovZ, + planLat: w.y, + planLon: w.x, + isOffset: true, + parentUuid: t.uuid, + offsetDist: dist, + offsetBearing: bearing, + ); + final id = await _db.insertStakeoutPoint(offset); + final saved = offset.copyWith(id: id); + points.add(saved); + setTarget(saved); + return saved; + } +}