Kitűzés: kényelmi funkciók, pontok listája, export
This commit is contained in:
@@ -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<void> show() {
|
||||
return Get.bottomSheet(
|
||||
const StakeoutListSheet(),
|
||||
isScrollControlled: true,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<StakeoutListSheet> createState() => _StakeoutListSheetState();
|
||||
}
|
||||
|
||||
class _StakeoutListSheetState extends State<StakeoutListSheet> {
|
||||
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<StakeoutStatus?>(
|
||||
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<String?>(
|
||||
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<String>(
|
||||
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<bool>(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<void> _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);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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<void> _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<bool>(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'),
|
||||
|
||||
@@ -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<void> 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<int>(
|
||||
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'),
|
||||
),
|
||||
],
|
||||
));
|
||||
}
|
||||
@@ -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<Project?> 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<Map<String, int>> 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<List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>> 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<int> 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<void> markSyncedByUuid(String table, List<String> 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<String?> 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<void> 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<int> upsertProjectFromRemote(Map<String, dynamic> remote) async {
|
||||
final db = await database;
|
||||
final existing = await getProjectByUuid(remote['id'] as String);
|
||||
|
||||
final map = <String, dynamic>{
|
||||
'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<void> applyRemoteRow(
|
||||
String table, Map<String, dynamic> 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<int?> 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<void> insertRemoteTrackPoints(List<Map<String, dynamic>> 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<int> 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<void> upsertImportedLayerRaw(Map<String, dynamic> map) async {
|
||||
final db = await database;
|
||||
await db.insert('imported_layers', map,
|
||||
conflictAlgorithm: ConflictAlgorithm.replace);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> exportAndShare({
|
||||
required List<StakeoutPoint> 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)' : ''}',
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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<GnssService>()) {
|
||||
ever(GnssService.to.lastGgaLine, (_) => _onPosition());
|
||||
}
|
||||
@@ -109,6 +129,24 @@ class StakeoutService extends GetxService {
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
Future<void> _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<void> 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<CoordConverterService>()) {
|
||||
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<StakeoutPoint?> 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 = <double>[], xs = <double>[], alts = <double>[];
|
||||
|
||||
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<double> l) => l.reduce((a, b) => a + b) / l.length;
|
||||
return (y: mean(ys), x: mean(xs), alt: mean(alts));
|
||||
}
|
||||
|
||||
Future<void> 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<void> 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<void> 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.
|
||||
|
||||
@@ -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<void> 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>() &&
|
||||
StakeoutService.to.active.value) {
|
||||
await StakeoutService.to.load();
|
||||
}
|
||||
} catch (e) {
|
||||
lastError.value = e.toString();
|
||||
} finally {
|
||||
isSyncing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _pushThenPull() async {
|
||||
await _push();
|
||||
return _pull();
|
||||
}
|
||||
|
||||
// ── PUSH ─────────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _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<bool> _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();
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user