diff --git a/lib/pages/map_survey/presentations/widgets/stakeout_list_sheet.dart b/lib/pages/map_survey/presentations/widgets/stakeout_list_sheet.dart new file mode 100644 index 0000000..561a836 --- /dev/null +++ b/lib/pages/map_survey/presentations/widgets/stakeout_list_sheet.dart @@ -0,0 +1,309 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; +import 'package:terepi_seged/services/project_service.dart'; +import 'package:terepi_seged/services/stakeout_export_service.dart'; +import 'package:terepi_seged/services/stakeout_service.dart'; + +import '../../../../models/stakeout_point.dart'; + +/// Kitűzési pontlista — bottom sheet a panelről. +/// +/// Szűrés státusz és vonal szerint; soronként távolság a jelenlegi +/// pozíciótól, kitűzötteknél az eltérések; koppintás = az legyen a cél; +/// menü: kihagyás / visszaállítás / törlés. Fejlécből CSV-export +/// (magyar Excel-barát vagy nemzetközi dialektus). +class StakeoutListSheet extends StatefulWidget { + const StakeoutListSheet({super.key}); + + static Future show() { + return Get.bottomSheet( + const StakeoutListSheet(), + isScrollControlled: true, + ); + } + + @override + State createState() => _StakeoutListSheetState(); +} + +class _StakeoutListSheetState extends State { + StakeoutStatus? _statusFilter; // null = mind + String? _lineFilter; // null = mind + + StakeoutService get svc => StakeoutService.to; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return SafeArea( + top: false, + child: Material( + color: colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + clipBehavior: Clip.antiAlias, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.sizeOf(context).height * 0.85), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 42, + height: 4, + margin: const EdgeInsets.only(top: 12, bottom: 8), + decoration: BoxDecoration( + color: colorScheme.outlineVariant, + borderRadius: BorderRadius.circular(999), + ), + ), + + // ── Fejléc + export ──────────────────────────────── + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Row( + children: [ + const Icon(Icons.list_alt), + const SizedBox(width: 8), + Obx(() => Text( + 'Kitűzési pontok (${svc.points.length})', + style: Theme.of(context).textTheme.titleMedium, + )), + const Spacer(), + IconButton( + icon: const Icon(Icons.ios_share), + tooltip: 'Export CSV', + onPressed: _exportDialog, + ), + ], + ), + ), + + // ── Szűrők ───────────────────────────────────────── + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + Expanded( + child: SegmentedButton( + segments: const [ + ButtonSegment(value: null, label: Text('Mind')), + ButtonSegment( + value: StakeoutStatus.pending, + label: Text('Vár')), + ButtonSegment( + value: StakeoutStatus.staked, + label: Text('Kész')), + ButtonSegment( + value: StakeoutStatus.skipped, + label: Text('Kihagyva')), + ], + selected: {_statusFilter}, + onSelectionChanged: (s) => + setState(() => _statusFilter = s.first), + showSelectedIcon: false, + style: const ButtonStyle( + visualDensity: VisualDensity.compact, + tapTargetSize: MaterialTapTargetSize.shrinkWrap), + ), + ), + const SizedBox(width: 8), + Obx(() { + final lines = svc.lines; + if (lines.length <= 1) return const SizedBox.shrink(); + return DropdownButton( + value: _lineFilter, + hint: + const Text('Vonal', style: TextStyle(fontSize: 12)), + items: [ + const DropdownMenuItem( + value: null, child: Text('Mind')), + for (final l in lines) + DropdownMenuItem( + value: l, + child: Text(l.isEmpty ? '(nincs)' : l)), + ], + onChanged: (v) => setState(() => _lineFilter = v), + ); + }), + ], + ), + ), + const Divider(height: 12), + + // ── Lista ────────────────────────────────────────── + Flexible( + child: Obx(() { + var rows = svc.points.toList(); + if (_statusFilter != null) { + rows = + rows.where((p) => p.status == _statusFilter).toList(); + } + if (_lineFilter != null) { + rows = rows.where((p) => p.lineId == _lineFilter).toList(); + } + rows.sort((a, b) => a.lineId != b.lineId + ? a.lineId.compareTo(b.lineId) + : a.station.compareTo(b.station)); + + if (rows.isEmpty) { + return const Padding( + padding: EdgeInsets.all(32), + child: Text('Nincs a szűrésnek megfelelő pont.'), + ); + } + + return ListView.builder( + shrinkWrap: true, + itemCount: rows.length, + itemBuilder: (context, i) => _row(context, rows[i]), + ); + }), + ), + ], + ), + ), + ), + ); + } + + Widget _row(BuildContext context, StakeoutPoint p) { + final isTarget = svc.target.value?.uuid == p.uuid; + + final (color, icon) = switch (p.status) { + StakeoutStatus.staked => (Colors.green, Icons.check_circle), + StakeoutStatus.skipped => (Colors.grey, Icons.block), + StakeoutStatus.pending => ( + Colors.deepOrange, + Icons.radio_button_unchecked + ), + }; + + // Távolság a jelenlegi pozíciótól (ha van). + String distText = ''; + if (svc.hasPosition.value) { + final dy = p.planEovY - svc.curEovY.value; + final dx = p.planEovX - svc.curEovX.value; + final d = math.sqrt(dy * dy + dx * dx); + distText = d >= 1000 + ? '${(d / 1000).toStringAsFixed(1)} km' + : '${d.toStringAsFixed(0)} m'; + } + + String cm(double? v) => v == null ? '—' : '${(v * 100).toStringAsFixed(0)}'; + + final sub = p.status == StakeoutStatus.staked + ? 'in ${cm(p.devInline)} · cross ${cm(p.devCrossline)}' + '${p.devDz != null ? ' · dZ ${cm(p.devDz)}' : ''} cm' + : [ + if (distText.isNotEmpty) distText, + if (p.isOffset) 'eltolt (${p.offsetDist?.toStringAsFixed(1)} m)', + ].join(' · '); + + return ListTile( + dense: true, + selected: isTarget, + leading: Icon(icon, color: color, size: 20), + title: Text(p.displayId, + style: TextStyle( + fontWeight: isTarget ? FontWeight.w700 : FontWeight.w500)), + subtitle: + sub.isEmpty ? null : Text(sub, style: const TextStyle(fontSize: 11)), + onTap: () { + svc.setTarget(p); + Get.back(); + }, + trailing: PopupMenuButton( + onSelected: (a) async { + switch (a) { + case 'target': + svc.setTarget(p); + Get.back(); + case 'skip': + await svc.setPointStatus(p, StakeoutStatus.skipped); + case 'restore': + await svc.setPointStatus(p, StakeoutStatus.pending); + case 'delete': + final ok = await Get.dialog(AlertDialog( + title: const Text('Pont törlése'), + content: Text('${p.displayId} törlése a kitűzési listából?'), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text('Mégse')), + FilledButton( + onPressed: () => Get.back(result: true), + child: const Text('Törlés')), + ], + )); + if (ok == true) await svc.deletePoint(p); + } + }, + itemBuilder: (_) => [ + const PopupMenuItem(value: 'target', child: Text('Ez legyen a cél')), + if (p.status != StakeoutStatus.skipped) + const PopupMenuItem(value: 'skip', child: Text('Kihagyás')), + if (p.status != StakeoutStatus.pending) + const PopupMenuItem( + value: 'restore', child: Text('Visszaállítás várakozóra')), + const PopupMenuItem(value: 'delete', child: Text('Törlés')), + ], + ), + ); + } + + // ── Export dialógus ──────────────────────────────────────────── + + Future _exportDialog() async { + final hungarian = true.obs; + final stakedOnly = false.obs; + + await Get.dialog(AlertDialog( + title: const Text('Export CSV'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Obx(() => SwitchListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: const Text('Magyar Excel-formátum'), + subtitle: const Text('pontosvessző + tizedesvessző', + style: TextStyle(fontSize: 11)), + value: hungarian.value, + onChanged: (v) => hungarian.value = v, + )), + Obx(() => SwitchListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: const Text('Csak a kitűzött pontok'), + value: stakedOnly.value, + onChanged: (v) => stakedOnly.value = v, + )), + ], + ), + actions: [ + TextButton(onPressed: Get.back, child: const Text('Mégse')), + FilledButton.icon( + icon: const Icon(Icons.ios_share, size: 18), + label: const Text('Export'), + onPressed: () async { + Get.back(); + try { + await StakeoutExportService.exportAndShare( + points: svc.points.toList(), + hungarian: hungarian.value, + stakedOnly: stakedOnly.value, + projectName: ProjectService.to.activeProject.value?.name ?? '', + ); + } catch (e) { + Get.snackbar('Export', e.toString(), + snackPosition: SnackPosition.BOTTOM); + } + }, + ), + ], + )); + } +} diff --git a/lib/pages/map_survey/presentations/widgets/stakeout_panel.dart b/lib/pages/map_survey/presentations/widgets/stakeout_panel.dart index 692c853..181f827 100644 --- a/lib/pages/map_survey/presentations/widgets/stakeout_panel.dart +++ b/lib/pages/map_survey/presentations/widgets/stakeout_panel.dart @@ -3,6 +3,8 @@ import 'dart:ui' show FontFeature; import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:terepi_seged/pages/map_survey/presentations/widgets/stakeout_list_sheet.dart'; +import 'package:terepi_seged/pages/map_survey/presentations/widgets/stakeout_settings_dialog.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'; @@ -88,7 +90,7 @@ class StakeoutPanel extends StatelessWidget { children: [ Text(t.displayId, style: const TextStyle( - fontSize: 17, fontWeight: FontWeight.w700)), + fontSize: 12, fontWeight: FontWeight.w700)), if (progress != null) Text( '${progress.staked}/${progress.total} kitűzve' @@ -132,6 +134,18 @@ class StakeoutPanel extends StatelessWidget { tooltip: 'Haptikus visszajelzés', onPressed: () => svc.hapticsEnabled.toggle(), )), + IconButton( + icon: const Icon(Icons.list_alt), + visualDensity: VisualDensity.compact, + tooltip: 'Pontlista / export', + onPressed: () => StakeoutListSheet.show(), + ), + IconButton( + icon: Icon(Icons.tune), + visualDensity: VisualDensity.compact, + tooltip: 'Kitűzési beálítások', + onPressed: () => showStakeoutSettingsDialog(), + ), IconButton( icon: const Icon(Icons.upload_file), visualDensity: VisualDensity.compact, @@ -160,28 +174,60 @@ class StakeoutPanel extends StatelessWidget { children: [ IconButton( icon: Icon(svc.nearPhase.value ? Icons.map : Icons.adjust), + visualDensity: VisualDensity.compact, + padding: EdgeInsets.zero, + constraints: + const BoxConstraints(minWidth: 36, minHeight: 36), tooltip: svc.nearPhase.value ? 'Térkép-nézet' : 'Céltábla-nézet', onPressed: svc.togglePhase, ), OutlinedButton( onPressed: svc.skipCurrent, + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 10), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), child: const Text('Kihagy'), ), - const SizedBox(width: 8), + const SizedBox(width: 6), OutlinedButton.icon( onPressed: () => _offsetDialog(svc), + style: OutlinedButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 10), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), 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'), + const SizedBox(width: 6), + // Expanded: a Tárol tölti ki a maradék helyet — + // fix-szélesség-összeg helyett, így nincs overflow. + Expanded( + child: FilledButton.icon( + onPressed: + svc.isAveraging.value ? null : () => _store(svc), + style: FilledButton.styleFrom( + backgroundColor: within ? Colors.green : null, + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric(horizontal: 10), + ), + icon: svc.isAveraging.value + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2)) + : const Icon(Icons.push_pin, size: 18), + label: FittedBox( + fit: BoxFit.scaleDown, + child: Text(svc.isAveraging.value + ? 'Átlagolás ${svc.averagingElapsed.value}/' + '${svc.averagingSeconds.value} s' + : 'Tárol'), + ), + ), ), ], ), @@ -241,6 +287,7 @@ class StakeoutPanel extends StatelessWidget { Future _offsetDialog(StakeoutService svc) async { final distCtrl = TextEditingController(text: '2'); final toRight = true.obs; + final both = false.obs; await Get.dialog(AlertDialog( title: const Text('Eltolt pont (transzverzális)'), @@ -284,6 +331,15 @@ class StakeoutPanel extends StatelessWidget { selected: {toRight.value}, onSelectionChanged: (s) => toRight.value = s.first, )), + Obx(() => CheckboxListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: const Text('Pár létrehozása (bal ÉS jobb)'), + subtitle: const Text('az akadály két oldalára', + style: TextStyle(fontSize: 11)), + value: both.value, + onChanged: (v) => both.value = v ?? false, + )) ], ), actions: [ @@ -292,14 +348,45 @@ class StakeoutPanel extends StatelessWidget { onPressed: () async { final d = double.tryParse(distCtrl.text.replaceAll(',', '.')); if (d == null || d <= 0) return; + + // Megrendelői offset-korlát ellenőrzése (beállításokból). + if (svc.maxOffset.value > 0 && d > svc.maxOffset.value) { + final ok = await Get.dialog(AlertDialog( + icon: const Icon(Icons.warning_amber, color: Colors.orange), + title: const Text('Offset-korlát túllépése'), + content: Text('A megadott ${d.toStringAsFixed(1)} m nagyobb ' + 'a beállított max. ${svc.maxOffset.value.toStringAsFixed(1)} m ' + 'crossline eltolásnál.'), + actions: [ + TextButton( + onPressed: () => Get.back(result: false), + child: const Text('Mégse')), + FilledButton( + onPressed: () => Get.back(result: true), + child: const Text('Mégis')), + ], + )); + if (ok != true) 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.', + if (both.value) { + // Előbb az ellenoldali, aztán a választott — így a + // választott oldal lesz az aktív cél. + await svc.createOffset(dist: d, toRight: !toRight.value); + await svc.createOffset(dist: d, toRight: toRight.value); + Get.snackbar('Eltolt pontpár létrehozva', + '±${d.toStringAsFixed(2)} m a vonalra merőlegesen.', snackPosition: SnackPosition.BOTTOM); + } else { + 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'), diff --git a/lib/pages/map_survey/presentations/widgets/stakeout_settings_dialog.dart b/lib/pages/map_survey/presentations/widgets/stakeout_settings_dialog.dart new file mode 100644 index 0000000..90c9aa3 --- /dev/null +++ b/lib/pages/map_survey/presentations/widgets/stakeout_settings_dialog.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; +import 'package:get/get.dart'; + +import '../../../../services/stakeout_service.dart'; + +/// Kitűzési beállítások dialógus — a panel fogaskerék-ikonjáról. +/// Minden érték a StakeoutService Rx-eiben él, és a service perzisztálja +/// (SharedPreferences), így app-újraindítás után is megmaradnak. +Future showStakeoutSettingsDialog() async { + final svc = StakeoutService.to; + + final tolCtrl = TextEditingController( + text: (svc.toleranceXY.value * 100).toStringAsFixed(0)); + final maxOffCtrl = TextEditingController( + text: svc.maxOffset.value > 0 + ? svc.maxOffset.value.toStringAsFixed(1) + : ''); + + await Get.dialog(AlertDialog( + title: const Text('Kitűzési beállítások'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: tolCtrl, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: 'Tűrés (cm)', + helperText: 'A céltábla zöld köre és a "tűrésen belül" jelzés', + isDense: true, + ), + ), + const SizedBox(height: 14), + const Text('Átlagolás tároláskor', + style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600)), + const SizedBox(height: 6), + Obx(() => SegmentedButton( + segments: const [ + ButtonSegment(value: 0, label: Text('Ki')), + ButtonSegment(value: 3, label: Text('3 s')), + ButtonSegment(value: 5, label: Text('5 s')), + ButtonSegment(value: 10, label: Text('10 s')), + ], + selected: {svc.averagingSeconds.value}, + onSelectionChanged: (s) => svc.averagingSeconds.value = s.first, + showSelectedIcon: false, + style: const ButtonStyle(visualDensity: VisualDensity.compact), + )), + const SizedBox(height: 4), + Text( + 'Bekapcsolva a "Tárol" a megadott ideig gyűjti és átlagolja ' + 'a pozíciókat — csökkenti a pillanatnyi zaj hatását.', + style: TextStyle(fontSize: 11, color: Colors.grey.shade600), + ), + const SizedBox(height: 10), + Obx(() => SwitchListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: const Text('Haptikus visszajelzés'), + subtitle: const Text('közeledve sűrűsödő rezgés', + style: TextStyle(fontSize: 11)), + value: svc.hapticsEnabled.value, + onChanged: (v) => svc.hapticsEnabled.value = v, + )), + Obx(() => SwitchListTile( + contentPadding: EdgeInsets.zero, + dense: true, + title: const Text('Hangjelzés'), + subtitle: const Text('kattanás a pulzusokkal, hang a tűrésnél', + style: TextStyle(fontSize: 11)), + value: svc.soundEnabled.value, + onChanged: (v) => svc.soundEnabled.value = v, + )), + const SizedBox(height: 6), + TextField( + controller: maxOffCtrl, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + decoration: const InputDecoration( + labelText: 'Max. crossline eltolás (m, üres = nincs korlát)', + labelStyle: TextStyle(fontSize: 12), + helperText: 'A specifikáció szerinti offset-korlát — ' + 'e fölött az eltolás-dialógus figyelmeztet.', + helperMaxLines: 2, + isDense: true, + ), + ), + ], + ), + ), + actions: [ + TextButton(onPressed: Get.back, child: const Text('Mégse')), + FilledButton( + onPressed: () async { + final tolCm = double.tryParse(tolCtrl.text.replaceAll(',', '.')); + if (tolCm != null && tolCm > 0) { + svc.toleranceXY.value = tolCm / 100.0; + } + final maxOff = double.tryParse(maxOffCtrl.text.replaceAll(',', '.')); + svc.maxOffset.value = maxOff ?? 0; + + await svc.saveSettings(); + Get.back(); + }, + child: const Text('Mentés'), + ), + ], + )); +} diff --git a/lib/services/app_database.dart b/lib/services/app_database.dart index 8d670be..411f7e1 100644 --- a/lib/services/app_database.dart +++ b/lib/services/app_database.dart @@ -277,6 +277,14 @@ class AppDatabase { return rows.isEmpty ? null : Project.fromMap(rows.first); } + /// Projekt keresése a globális uuid alapján — közös projekthez + /// csatlakozáskor ezzel derül ki, megvan-e már lokálisan. + Future getProjectByUuid(String uuid) async { + final rows = await (await database) + .query('projects', where: 'uuid = ?', whereArgs: [uuid], limit: 1); + return rows.isEmpty ? null : Project.fromMap(rows.first); + } + // Projekt statisztikák — a listázáshoz Future> getProjectStats(int projectId) async { final db = await database; @@ -787,4 +795,249 @@ class AppDatabase { orderBy: 'line_id ASC, station ASC'); return rows.map(StakeoutPoint.fromMap).toList(); } + + /// Feltöltésre váró kitűzési pontok — a projekt uuid-jával együtt. + /// A csak lokális projektek SQL-szinten kizárva. + Future>> pendingStakeoutPoints() async { + final db = await database; + return db.rawQuery(''' + SELECT sp.*, pr.uuid AS project_uuid + FROM stakeout_points sp + JOIN projects pr ON pr.id = sp.project_id + WHERE sp.sync_status = 'pending' AND pr.is_local_only = 0 + ORDER BY sp.id ASC + '''); + } + + // ═══════════════════════════════════════════════════════════════ + // SZINKRON-SEGÉDEK — a TsSyncService használja őket + // ═══════════════════════════════════════════════════════════════ + // + // FONTOS: minden lekérdezés kihagyja az is_local_only = 1 projekteket + // és azok adatait — a "csak lokális" projekt SOHA nem hagyja el az + // eszközt, semmilyen szinkron-útvonalon. + + /// Feltöltésre váró (nem lokális) projektek. + Future>> pendingProjects() async { + final db = await database; + return db.query('projects', + where: "is_local_only = 0 AND sync_status = 'pending'"); + } + + /// Feltöltésre váró bemért pontok — a projekt uuid-jával együtt. + Future>> pendingMeasuredPoints() async { + final db = await database; + return db.rawQuery(''' + SELECT mp.*, pr.uuid AS project_uuid + FROM measured_points mp + JOIN projects pr ON pr.id = mp.project_id + WHERE mp.sync_status = 'pending' AND pr.is_local_only = 0 + ORDER BY mp.id ASC + '''); + } + + /// Feltöltésre váró trackek — a projekt uuid-jával együtt. + Future>> pendingTracks() async { + final db = await database; + return db.rawQuery(''' + SELECT t.*, pr.uuid AS project_uuid + FROM tracks t + JOIN projects pr ON pr.id = t.project_id + WHERE t.sync_status = 'pending' AND pr.is_local_only = 0 + ORDER BY t.id ASC + '''); + } + + /// Feltöltésre váró track-pontok, batch-ben — track- és projekt-uuid-val. + /// Csak lezárt vagy futó, NEM törölt, NEM lokális trackek pontjai. + Future>> pendingTrackPoints( + {int limit = 500}) async { + final db = await database; + return db.rawQuery(''' + SELECT tp.*, t.uuid AS track_uuid, pr.uuid AS project_uuid + FROM track_points tp + JOIN tracks t ON t.id = tp.track_id + JOIN projects pr ON pr.id = t.project_id + WHERE tp.sync_status = 'pending' + AND t.deleted_at IS NULL + AND t.sync_status != 'pending' + AND pr.is_local_only = 0 + ORDER BY tp.id ASC + LIMIT ? + ''', [limit]); + } + + /// Feltöltésre váró terepbejárás-elemek (csak projekthez rendeltek). + Future>> pendingNoteItems() async { + final db = await database; + return db.rawQuery(''' + SELECT n.*, pr.uuid AS project_uuid + FROM note_items n + JOIN projects pr ON pr.id = n.project_id + WHERE n.sync_status = 'pending' AND pr.is_local_only = 0 + ORDER BY n.id ASC + '''); + } + + /// Összes feltöltésre váró sor száma (UI-jelvényhez). + Future pendingSyncCount() async { + final db = await database; + final res = await db.rawQuery(''' + SELECT + (SELECT COUNT(*) FROM projects p + WHERE p.is_local_only = 0 AND p.sync_status = 'pending') + + (SELECT COUNT(*) FROM measured_points mp + JOIN projects pr ON pr.id = mp.project_id + WHERE mp.sync_status = 'pending' AND pr.is_local_only = 0) + + (SELECT COUNT(*) FROM tracks t + JOIN projects pr ON pr.id = t.project_id + WHERE t.sync_status = 'pending' AND pr.is_local_only = 0) + + (SELECT COUNT(*) FROM track_points tp + JOIN tracks t ON t.id = tp.track_id + JOIN projects pr ON pr.id = t.project_id + WHERE tp.sync_status = 'pending' AND pr.is_local_only = 0) + AS cnt + '''); + return (res.first['cnt'] as int?) ?? 0; + } + + /// Sorok megjelölése szinkronizáltként, uuid alapján (500-as darabokban, + /// az SQLite paraméter-limit miatt). + Future markSyncedByUuid(String table, List uuids) async { + if (uuids.isEmpty) return; + final db = await database; + for (var i = 0; i < uuids.length; i += 500) { + final chunk = + uuids.sublist(i, i + 500 > uuids.length ? uuids.length : i + 500); + final placeholders = List.filled(chunk.length, '?').join(','); + await db.rawUpdate( + "UPDATE $table SET sync_status = 'synced' WHERE uuid IN ($placeholders)", + chunk, + ); + } + } + + // ── Szinkron-kurzor (projektenként) ────────────────────────────── + + Future getProjectCursor(int localProjectId) async { + final db = await database; + final rows = await db.query('projects', + columns: ['last_pulled_at'], + where: 'id = ?', + whereArgs: [localProjectId], + limit: 1); + return rows.isEmpty ? null : rows.first['last_pulled_at'] as String?; + } + + Future setProjectCursor(int localProjectId, String cursor) async { + final db = await database; + await db.update('projects', {'last_pulled_at': cursor}, + where: 'id = ?', whereArgs: [localProjectId]); + } + + // ── Távoli sorok lokális alkalmazása ───────────────────────────── + + /// Távoli projekt-sor lokális upsertje uuid alapján. + /// Visszaadja a lokális id-t. Csatlakozáskor és letöltéskor is ez fut. + Future upsertProjectFromRemote(Map remote) async { + final db = await database; + final existing = await getProjectByUuid(remote['id'] as String); + + final map = { + 'uuid': remote['id'], + 'name': remote['name'] ?? '', + 'client': remote['client'] ?? '', + 'description': remote['description'] ?? '', + 'crs': remote['crs'] ?? 'eov', + 'color': remote['color'] ?? '#185FA5', + 'status': remote['status'] ?? 'active', + 'is_local_only': 0, + 'updated_at': remote['updated_at'] ?? DateTime.now().toIso8601String(), + 'deleted_at': remote['deleted_at'], + 'sync_status': 'synced', + 'last_synced_at': DateTime.now().toIso8601String(), + }; + + if (existing == null) { + map['created_at'] = + remote['created_at'] ?? DateTime.now().toIso8601String(); + return db.insert('projects', map); + } + + // Lokális, még fel nem töltött módosítás nyer (push fut előbb). + final localPending = await db.query('projects', + columns: ['sync_status'], + where: 'id = ?', + whereArgs: [existing.id], + limit: 1); + if (localPending.first['sync_status'] == 'pending') return existing.id!; + + await db.update('projects', map, where: 'id = ?', whereArgs: [existing.id]); + return existing.id!; + } + + /// Általános távoli-sor alkalmazás uuid-kulcsú táblára + /// (measured_points / tracks / note_items). + /// + /// LWW-szabály: ha a lokális sor 'pending' (van el nem küldött helyi + /// módosítás), a távolit kihagyjuk — a push úgyis előbb fut, mint a pull. + Future applyRemoteRow( + String table, Map localMap) async { + final db = await database; + final uuid = localMap['uuid'] as String; + final rows = await db.query(table, + columns: ['id', 'sync_status'], + where: 'uuid = ?', + whereArgs: [uuid], + limit: 1); + + if (rows.isEmpty) { + // Ismeretlen sor tombstone-ja: nincs mit törölni, kihagyjuk. + if (localMap['deleted_at'] != null) return; + await db.insert(table, localMap); + return; + } + if (rows.first['sync_status'] == 'pending') return; + + await db.update(table, localMap, + where: 'id = ?', whereArgs: [rows.first['id']]); + } + + /// Track lokális id-ja uuid alapján (a track-pontok letöltéséhez). + Future getTrackIdByUuid(String uuid) async { + final db = await database; + final rows = await db.query('tracks', + columns: ['id'], where: 'uuid = ?', whereArgs: [uuid], limit: 1); + return rows.isEmpty ? null : rows.first['id'] as int; + } + + /// Távoli track-pontok beszúrása — a uuid unique index miatt az + /// ismétlődések csendben kimaradnak (idempotens). + Future insertRemoteTrackPoints(List> maps) async { + if (maps.isEmpty) return; + final db = await database; + final batch = db.batch(); + for (final m in maps) { + batch.insert('track_points', m, + conflictAlgorithm: ConflictAlgorithm.ignore); + } + await batch.commit(noResult: true); + // A point_count/distance a saját eszközön mérésnél frissül; letöltött + // pontoknál a track sora már a helyes összesítőkkel érkezik a szerverről. + } + + // ── Réteg-szinkron segédek ─────────────────────────────────────── + + Future getImportedLayerVersion(String id) async { + final db = await database; + final rows = await db.query('imported_layers', + columns: ['version'], where: 'id = ?', whereArgs: [id], limit: 1); + return rows.isEmpty ? -1 : (rows.first['version'] as int? ?? 0); + } + + Future upsertImportedLayerRaw(Map map) async { + final db = await database; + await db.insert('imported_layers', map, + conflictAlgorithm: ConflictAlgorithm.replace); + } } diff --git a/lib/services/ntrip_service.dart b/lib/services/ntrip_service.dart index c6df4b0..16b5bbd 100644 --- a/lib/services/ntrip_service.dart +++ b/lib/services/ntrip_service.dart @@ -357,6 +357,7 @@ class NtripService extends GetxService { void _forwardRtcm(Uint8List data) { receivedBytes.value += data.length; // kumulált (a korábbi = felülírt) packetCount.value++; + lastRtcmAt.value = DateTime.now(); onRtcmData?.call(data); } diff --git a/lib/services/stakeout_export_service.dart b/lib/services/stakeout_export_service.dart new file mode 100644 index 0000000..c5ea2fd --- /dev/null +++ b/lib/services/stakeout_export_service.dart @@ -0,0 +1,143 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../../models/stakeout_point.dart'; + +/// Kitűzési jegyzőkönyv CSV-export. +/// +/// Két dialektus: +/// * MAGYAR (Excel-barát): pontosvessző elválasztó, tizedesVESSZŐ, +/// UTF-8 BOM-mal (enélkül az Excel elrontja az ékezeteket) +/// * NEMZETKÖZI: vessző elválasztó, tizedespont +/// +/// A jegyzőkönyv a teljes dokumentációt tartalmazza: terv- ÉS mért +/// koordináták, inline/crossline/dZ eltérések, fix-minőség, pontosság, +/// időpont, eszköz, valamint az eltolt pontoknál az eltolás-vektor. +class StakeoutExportService { + StakeoutExportService._(); + + static Future exportAndShare({ + required List points, + required bool hungarian, + required bool stakedOnly, + String projectName = '', + }) async { + final rows = stakedOnly + ? points.where((p) => p.status == StakeoutStatus.staked).toList() + : List.of(points); + if (rows.isEmpty) { + throw StateError('Nincs exportálható pont.'); + } + rows.sort((a, b) => a.lineId != b.lineId + ? a.lineId.compareTo(b.lineId) + : a.station.compareTo(b.station)); + + final sep = hungarian ? ';' : ','; + + String num(double? v, [int dec = 3]) { + if (v == null) return ''; + final s = v.toStringAsFixed(dec); + return hungarian ? s.replaceAll('.', ',') : s; + } + + String txt(String s) => + s.contains(sep) || s.contains('"') || s.contains('\n') + ? '"${s.replaceAll('"', '""')}"' + : s; + + String status(StakeoutStatus s) => switch (s) { + StakeoutStatus.pending => 'varakozik', + StakeoutStatus.staked => 'kituzve', + StakeoutStatus.skipped => 'kihagyva', + }; + + String fix(int? q) => switch (q) { + 4 => 'RTK_FIXED', + 5 => 'RTK_FLOAT', + 2 => 'DGPS', + 1 => 'GPS', + 0 => 'NO_FIX', + null => '', + _ => 'Q$q', + }; + + final header = [ + 'Vonal', + 'Allomas', + 'Nev', + 'Tipus', + 'Statusz', + 'Terv_EOV_Y', + 'Terv_EOV_X', + 'Terv_EOV_Z', + 'Mert_EOV_Y', + 'Mert_EOV_X', + 'Mert_EOV_Z', + 'Inline_m', + 'Crossline_m', + 'dZ_m', + 'Fix', + 'Pontossag_m', + 'Kituzes_ideje', + 'Eltolt', + 'Eltolas_m', + 'Eltolas_irany_fok', + 'Eszkoz_id', + 'Forras', + ].join(sep); + + final sb = StringBuffer(); + sb.write('\uFEFF'); // UTF-8 BOM — az Excel enélkül elrontja az ékezeteket + sb.writeln(header); + + for (final r in rows) { + sb.writeln([ + txt(r.lineId), + r.station.toString(), + txt(r.name), + txt(r.pointType), + status(r.status), + num(r.planEovY), + num(r.planEovX), + num(r.planEovZ), + num(r.measuredEovY), + num(r.measuredEovX), + num(r.measuredEovZ), + num(r.devInline), + num(r.devCrossline), + num(r.devDz), + fix(r.fixQuality), + num(r.accuracy, 2), + r.stakedAt?.toIso8601String() ?? '', + r.isOffset ? '1' : '0', + num(r.offsetDist, 2), + num(r.offsetBearing, 1), + txt(r.deviceId ?? ''), + txt(r.source), + ].join(sep)); + } + + final dir = await getTemporaryDirectory(); + final stamp = DateTime.now() + .toIso8601String() + .substring(0, 16) + .replaceAll(':', '') + .replaceAll('-', '') + .replaceAll('T', '_'); + final safeName = projectName.isEmpty + ? 'kituzes' + : projectName.replaceAll(RegExp(r'[^\w\-]'), '_'); + final path = p.join(dir.path, '${safeName}_$stamp.csv'); + await File(path).writeAsString(sb.toString(), flush: true); + + await SharePlus.instance.share(ShareParams( + files: [XFile(path, mimeType: 'text/csv')], + subject: 'Kitűzési jegyzőkönyv — $projectName', + text: '${rows.length} pont' + '${stakedOnly ? ' (csak kitűzöttek)' : ''}', + )); + } +} diff --git a/lib/services/stakeout_service.dart b/lib/services/stakeout_service.dart index 0895b97..0331dd5 100644 --- a/lib/services/stakeout_service.dart +++ b/lib/services/stakeout_service.dart @@ -3,10 +3,13 @@ import 'dart:math' as math; import 'package:flutter/services.dart'; import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:terepi_seged/controls/geoid_grid.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:wakelock_plus/wakelock_plus.dart'; import '../../models/stakeout_point.dart'; @@ -49,11 +52,13 @@ class StakeoutService extends GetxService { if (active.value == value) return; active.value = value; if (value) { + WakelockPlus.enable(); await load(); _applyPhase(); _scheduleHaptic(); } else { _hapticTimer?.cancel(); + WakelockPlus.disable(); } } @@ -74,6 +79,18 @@ class StakeoutService extends GetxService { final hapticsEnabled = true.obs; + final soundEnabled = true.obs; + + /// Átlagolás tároláskor + final averagingSeconds = 0.obs; + final isAveraging = false.obs; + final averagingElapsed = 0.obs; + + /// Max. megengedett crossline eltolás (m; 0=nincs korlát) + final maxOffset = 0.0.obs; + + late GeoidGrid geoidGrid; + // ── Aktuális pozíció (EOV) és navigációs értékek ──────────────── final hasPosition = false.obs; final curEovY = 0.0.obs; @@ -96,8 +113,11 @@ class StakeoutService extends GetxService { bool _toleranceAnnounced = false; @override - void onInit() { + void onInit() async { super.onInit(); + await _loadSettings(); + geoidGrid = await GeoidGrid.load('assets/Grids/geoid_eht2014.gtx'); + if (Get.isRegistered()) { ever(GnssService.to.lastGgaLine, (_) => _onPosition()); } @@ -109,6 +129,24 @@ class StakeoutService extends GetxService { super.onClose(); } + Future _loadSettings() async { + final prefs = await SharedPreferences.getInstance(); + toleranceXY.value = prefs.getDouble('stakeout_tolerance') ?? 0.03; + averagingSeconds.value = prefs.getInt('stakeout_avg_seconds') ?? 0; + hapticsEnabled.value = prefs.getBool('stakeout_haptics') ?? true; + soundEnabled.value = prefs.getBool('stakeout_sound') ?? true; + maxOffset.value = prefs.getDouble('stakeout_max_offset') ?? 0; + } + + Future saveSettings() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setDouble('stakeout_tolerance', toleranceXY.value); + await prefs.setInt('stakeout_avg_seconds', averagingSeconds.value); + await prefs.setBool('stakeout_haptics', hapticsEnabled.value); + await prefs.setBool('stakeout_sound', soundEnabled.value); + await prefs.setDouble('stakeout_max_offset', maxOffset.value); + } + // ═════════════════════════════════════════════════════════════════ // Betöltés / cél-kezelés // ═════════════════════════════════════════════════════════════════ @@ -227,12 +265,18 @@ class StakeoutService extends GetxService { !Get.isRegistered()) { return; } + final lat = gnss.latitude.value; + final lon = gnss.longitude.value; + final alt = gnss.altitude.value; + final sep = gnss.geoidSeparation.value; - final p = CoordConverterService.to - .wgsToEovPoint(gnss.longitude.value, gnss.latitude.value); + final p = CoordConverterService.to.wgsToEovPoint(lon, lat); curEovY.value = p.x; curEovX.value = p.y; - curAlt.value = gnss.altitude.value; + + curAlt.value = geoidGrid.toEovHeight(lat, lon, alt, sep)!; + + //curAlt.value = gnss.altitude.value; hasPosition.value = true; // Haladási irány: legalább 0,5 m elmozdulásból (állva zajos lenne). @@ -287,7 +331,7 @@ class StakeoutService extends GetxService { if (withinTolerance.value && !wasWithin && !_toleranceAnnounced) { _toleranceAnnounced = true; if (hapticsEnabled.value) HapticFeedback.heavyImpact(); - SystemSound.play(SystemSoundType.alert); + if (soundEnabled.value) SystemSound.play(SystemSoundType.alert); } else if (!withinTolerance.value) { _toleranceAnnounced = false; } @@ -330,6 +374,7 @@ class StakeoutService extends GetxService { } else { HapticFeedback.lightImpact(); } + if (soundEnabled.value) SystemSound.play(SystemSoundType.click); _scheduleHaptic(); }); } @@ -380,15 +425,29 @@ class StakeoutService extends GetxService { // 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. + /// Tárolás a jelenlegi (vagy átlagolt) mért pozícióval. Ha az + /// [averagingSeconds] > 0, a megadott ideig gyűjti és átlagolja a + /// pozíciókat — csökkenti a pillanatnyi zaj hatását. 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; + if (t == null || !hasPosition.value || isAveraging.value) return null; final gnss = GnssService.to; - final dy = t.planEovY - curEovY.value; - final dx = t.planEovX - curEovX.value; + double useY = curEovY.value; + double useX = curEovX.value; + double useAlt = curAlt.value; + + if (averagingSeconds.value > 0) { + final avg = await _collectAverage(averagingSeconds.value); + useY = avg.y; + useX = avg.x; + useAlt = avg.alt; + } + + final dy = t.planEovY - useY; + final dx = t.planEovX - useX; final lineBearing = lineBearingAt(t); final rad = (lineBearing ?? 0) * math.pi / 180; final inline = @@ -396,16 +455,18 @@ class StakeoutService extends GetxService { final crossline = lineBearing != null ? dy * math.cos(rad) - dx * math.sin(rad) : dy; + final w = CoordConverterService.to.eovToWgsPoint(useY, useX); + final updated = t.copyWith( status: StakeoutStatus.staked, - measuredEovY: curEovY.value, - measuredEovX: curEovX.value, - measuredEovZ: curAlt.value, - measuredLat: gnss.latitude.value, - measuredLon: gnss.longitude.value, + measuredEovY: useY, + measuredEovX: useX, + measuredEovZ: useAlt, + measuredLat: w.y, + measuredLon: w.x, devInline: inline, devCrossline: crossline, - devDz: t.planEovZ != null ? t.planEovZ! - curAlt.value : null, + devDz: t.planEovZ != null ? t.planEovZ! - useAlt : null, fixQuality: gnss.gpsQuality.value, accuracy: gnss.horizontalAccuracy, stakedAt: DateTime.now(), @@ -417,6 +478,36 @@ class StakeoutService extends GetxService { return updated; } + /// Pozíció-átlagolás: [seconds] másodpercig minden GGA-pozíciót + /// gyűjt, majd átlagol. Ha nem jött minta, a pillanatnyi értéket adja. + Future<({double y, double x, double alt})> _collectAverage( + int seconds) async { + isAveraging.value = true; + averagingElapsed.value = 0; + final ys = [], xs = [], alts = []; + + final worker = ever(curEovY, (_) { + ys.add(curEovY.value); + xs.add(curEovX.value); + alts.add(curAlt.value); + }); + final ticker = Timer.periodic( + const Duration(seconds: 1), (_) => averagingElapsed.value++); + try { + await Future.delayed(Duration(seconds: seconds)); + } finally { + worker.dispose(); + ticker.cancel(); + isAveraging.value = false; + } + + if (ys.isEmpty) { + return (y: curEovY.value, x: curEovX.value, alt: curAlt.value); + } + double mean(List l) => l.reduce((a, b) => a + b) / l.length; + return (y: mean(ys), x: mean(xs), alt: mean(alts)); + } + Future skipCurrent() async { final t = target.value; if (t == null) return; @@ -428,6 +519,28 @@ class StakeoutService extends GetxService { advance(); } + /// Státusz-állítás a listából (kihagyás / visszaállítás). + Future setPointStatus(StakeoutPoint p, StakeoutStatus status) async { + final updated = p.copyWith(status: status); + await _db.updateStakeoutPoint(updated); + final i = points.indexWhere((e) => e.uuid == p.uuid); + if (i >= 0) points[i] = updated; + points.refresh(); + if (target.value?.uuid == p.uuid && status != StakeoutStatus.pending) { + advance(); + } + } + + /// Pont törlése (soft delete — a szinkron viszi a többi eszközre is). + Future deletePoint(StakeoutPoint p) async { + await _db.softDeleteStakeoutPoint(p.id!); + points.removeWhere((e) => e.uuid == p.uuid); + if (target.value?.uuid == p.uuid) { + target.value = _firstPending(); + _recompute(); + } + } + /// 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. diff --git a/lib/services/stakeout_sync_service.dart b/lib/services/stakeout_sync_service.dart new file mode 100644 index 0000000..2c9617d --- /dev/null +++ b/lib/services/stakeout_sync_service.dart @@ -0,0 +1,188 @@ +import 'package:get/get.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; +import 'package:terepi_seged/services/app_database.dart'; + +import 'stakeout_service.dart'; + +/// Kitűzési pontok Supabase-szinkronja — a meglévő outbox-mintára. +/// +/// A TsSyncService hívja minden ciklus végén (mint a LayerSyncService-t), +/// de kézzel (syncNow) is futtatható. PUSH: a 'pending' sorok uuid-ra +/// upserttel (idempotens); PULL: projektenként inkrementálisan +/// (updated_at > kurzor). A csak-lokális projektek kizárását az +/// AppDatabase.pendingStakeoutPoints() végzi SQL-szinten, a pull pedig +/// csak a nem-lokális projekteken iterál. +/// +/// SAJÁT kurzort használ (SharedPreferences, projektenként), mert a +/// TsSyncService a projects.last_pulled_at kurzort a saját pullja végén +/// már előretolta — közös kurzorral a kitűzés-sorok kimaradhatnának. +/// +/// Csapat-láthatóság: a letöltött sorok a lokális DB-be kerülnek, a +/// StakeoutService.load() frissíti a listát, így a MÁS ESZKÖZÖN kitűzött +/// pont a térképen magától zöldre vált. +class StakeoutSyncService extends GetxService { + static StakeoutSyncService get to => Get.find(); + + final isSyncing = false.obs; + final lastError = ''.obs; + + SupabaseClient get _client => Supabase.instance.client; + AppDatabase get _db => AppDatabase.instance; + + Future sync() async { + if (_client.auth.currentUser == null) return; + if (isSyncing.value) return; + isSyncing.value = true; + lastError.value = ''; + try { + final pulledAny = await _pushThenPull(); + // Ha aktív a kitűzés mód és jött új adat, frissítjük a nézetet — + // itt válik láthatóvá a többiek munkája (zöld markerek). + if (pulledAny && + Get.isRegistered() && + StakeoutService.to.active.value) { + await StakeoutService.to.load(); + } + } catch (e) { + lastError.value = e.toString(); + } finally { + isSyncing.value = false; + } + } + + Future _pushThenPull() async { + await _push(); + return _pull(); + } + + // ── PUSH ───────────────────────────────────────────────────────── + + Future _push() async { + final rows = await _db.pendingStakeoutPoints(); + if (rows.isEmpty) return; + + final payload = rows + .map((r) => { + 'id': r['uuid'], + 'project_id': r['project_uuid'], + 'line_id': r['line_id'], + 'station': r['station'], + 'name': r['name'], + 'point_type': r['point_type'], + 'source': r['source'], + 'plan_eov_y': r['plan_eov_y'], + 'plan_eov_x': r['plan_eov_x'], + 'plan_eov_z': r['plan_eov_z'], + 'plan_lat': r['plan_lat'], + 'plan_lon': r['plan_lon'], + 'status': r['status'], + 'measured_eov_y': r['measured_eov_y'], + 'measured_eov_x': r['measured_eov_x'], + 'measured_eov_z': r['measured_eov_z'], + 'measured_lat': r['measured_lat'], + 'measured_lon': r['measured_lon'], + 'dev_inline': r['dev_inline'], + 'dev_crossline': r['dev_crossline'], + 'dev_dz': r['dev_dz'], + 'fix_quality': r['fix_quality'], + 'accuracy': r['accuracy'], + 'tilt_deg': r['tilt_deg'], + 'staked_at': _toUtc(r['staked_at'] as String?), + 'is_offset': (r['is_offset'] as int? ?? 0) == 1, + 'parent_uuid': r['parent_uuid'], + 'offset_dist': r['offset_dist'], + 'offset_bearing': r['offset_bearing'], + 'device_id': r['device_id'], + 'deleted_at': _toUtc(r['deleted_at'] as String?), + }) + .toList(); + + await _client.from('ts_stakeout_points').upsert(payload); + await _db.markSyncedByUuid( + 'stakeout_points', rows.map((r) => r['uuid'] as String).toList()); + } + + // ── PULL ───────────────────────────────────────────────────────── + + Future _pull() async { + final prefs = await SharedPreferences.getInstance(); + var pulledAny = false; + + final projects = await _db.listProjects(); + for (final project in projects.where((p) => !p.isLocalOnly)) { + final cursorKey = 'stakeout_cursor_${project.uuid}'; + final cursor = prefs.getString(cursorKey) ?? '1970-01-01T00:00:00Z'; + var maxSeen = cursor; + + final rows = await _client + .from('ts_stakeout_points') + .select() + .eq('project_id', project.uuid) + .gt('updated_at', cursor) + .order('updated_at', ascending: true) + .limit(1000); + + for (final r in rows) { + final ts = r['updated_at'] as String?; + if (ts != null && DateTime.parse(ts).isAfter(DateTime.parse(maxSeen))) { + maxSeen = ts; + } + + // Az AppDatabase generikus applyRemoteRow-ja: uuid alapján + // beszúr/frissít, a lokális 'pending' sort nem írja felül + // (a push előbb futott). + await _db.applyRemoteRow('stakeout_points', { + 'uuid': r['id'], + 'project_id': project.id, + 'line_id': r['line_id'] ?? '', + 'station': r['station'], + 'name': r['name'] ?? '${r['station']}', + 'point_type': r['point_type'] ?? 'geofon', + 'source': r['source'] ?? 'supabase', + 'plan_eov_y': r['plan_eov_y'], + 'plan_eov_x': r['plan_eov_x'], + 'plan_eov_z': r['plan_eov_z'], + 'plan_lat': r['plan_lat'], + 'plan_lon': r['plan_lon'], + 'status': r['status'] ?? 'pending', + 'measured_eov_y': r['measured_eov_y'], + 'measured_eov_x': r['measured_eov_x'], + 'measured_eov_z': r['measured_eov_z'], + 'measured_lat': r['measured_lat'], + 'measured_lon': r['measured_lon'], + 'dev_inline': r['dev_inline'], + 'dev_crossline': r['dev_crossline'], + 'dev_dz': r['dev_dz'], + 'fix_quality': r['fix_quality'], + 'accuracy': r['accuracy'], + 'tilt_deg': r['tilt_deg'], + 'staked_at': r['staked_at'], + 'is_offset': (r['is_offset'] == true) ? 1 : 0, + 'parent_uuid': r['parent_uuid'], + 'offset_dist': r['offset_dist'], + 'offset_bearing': r['offset_bearing'], + 'created_by': r['created_by'], + 'device_id': r['device_id'], + 'created_at': r['created_at'] ?? DateTime.now().toIso8601String(), + 'updated_at': r['updated_at'], + 'deleted_at': r['deleted_at'], + 'sync_status': 'synced', + }); + pulledAny = true; + } + + if (maxSeen != cursor) { + await prefs.setString(cursorKey, maxSeen); + } + } + return pulledAny; + } + + /// A lokális, zóna nélküli időbélyeg a Postgres-ben UTC-nek számítana + /// → küldés előtt explicit UTC-re konvertálunk. + String? _toUtc(String? localIso) { + if (localIso == null || localIso.isEmpty) return null; + return DateTime.parse(localIso).toUtc().toIso8601String(); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 6fe5829..479e318 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -57,7 +57,7 @@ dependencies: google_fonts: ^6.2.1 # flutter_iconpicker: ^3.2.2 animated_text_kit: ^4.2.3 - package_info_plus: ^8.2.1 + package_info_plus: ^9.0.1 # flutter_map_geojson: ^1.0.8 # geojson: ^1.0.0 syncfusion_flutter_datagrid: ^28.2.6 @@ -81,6 +81,7 @@ dependencies: xml: ^7.0.1 shared_preferences: ^2.5.5 device_info_plus: ^12.4.0 + wakelock_plus: ^1.2.11 flutter: sdk: flutter