Jármű navigáció @3h

This commit is contained in:
2026-07-13 07:58:51 +02:00
parent 4bf73c6cc7
commit 0810c528ec
12 changed files with 1374 additions and 3 deletions
@@ -0,0 +1,216 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:get/get.dart';
import 'package:terepi_seged/models/source_point.dart';
import 'package:terepi_seged/models/vechicle_position_log.dart';
import 'package:terepi_seged/services/app_database.dart';
import 'package:terepi_seged/services/coord_converter_service.dart';
import 'package:terepi_seged/services/device_identity_service.dart';
import 'package:terepi_seged/services/gnss/gnss_service.dart';
import 'package:terepi_seged/services/project_service.dart';
import 'package:terepi_seged/services/vechicle_identity_service.dart';
/// Egyszerű navigációs panel a jelgerjesztő (vibrátor) járműhöz.
///
/// A pozíció a KITŰZÉSSEL AZONOS forrásból jön (GnssService — külső
/// BT/BLE GNSS-egység a járműben), semmilyen új eszköz-integráció nem
/// kell hozzá. A forráspontok tisztán referencia-réteg (nincs mutálható
/// "meglőve" állapotuk); az ellenőrzés a periodikus pozíciónapló és a
/// terv UTÓLAGOS összevetéséből adódik (lásd isVerified).
class VibroNavController extends GetxController {
AppDatabase get _db => AppDatabase.instance;
// ── Forráspontok ─────────────────────────────────────────────────
final sourcePoints = <SourcePoint>[].obs;
/// Egyezés-tűrés (m) a naplózott pozíció és a terv-forráspont között.
final tolerance = 15.0.obs;
// ── Élő pozíció (a GnssService-ből, EOV-ra konvertálva) ───────────
final hasPosition = false.obs;
final curEovY = 0.0.obs;
final curEovX = 0.0.obs;
final speedKmh = 0.0.obs;
final sessionDistanceM = 0.0.obs;
/// Haladási irány (fok) — pozíció-előzményből számolva, ALACSONY
/// SEBESSÉGNÉL BEFAGYASZTVA (a nyers GPS-heading álló/lassú helyzetben
/// zajos/értelmetlen — ugyanez a minta, mint a Kitűzésnél).
final travelHeading = Rxn<double>();
static const _headingFreezeSpeedKmh = 5.0;
double? _histY, _histX;
DateTime? _histTime;
// ── Legközelebbi forráspont ───────────────────────────────────────
final nearestPoint = Rxn<SourcePoint>();
final nearestDistance = 0.0.obs;
// ── Periodikus napló ───────────────────────────────────────────────
final isLogging = false.obs;
final logIntervalSec = 60.obs; // felhasználó által állítható
Timer? _logTimer;
@override
void onInit() {
super.onInit();
load();
if (Get.isRegistered<GnssService>()) {
ever(GnssService.to.lastGgaLine, (_) => _onPosition());
}
}
@override
void onClose() {
_logTimer?.cancel();
super.onClose();
}
Future<void> load() async {
final projectId = ProjectService.to.activeProjectId;
if (projectId == null) {
sourcePoints.clear();
return;
}
sourcePoints.value = await _db.listSourcePoints(projectId);
}
// ═════════════════════════════════════════════════════════════════
// Pozíció-feldolgozás
// ═════════════════════════════════════════════════════════════════
void _onPosition() {
final gnss = GnssService.to;
if (gnss.gpsQuality.value <= 0 ||
gnss.latitude.value == 0 ||
!Get.isRegistered<CoordConverterService>()) {
return;
}
final p = CoordConverterService.to
.wgsToEovPoint(gnss.longitude.value, gnss.latitude.value);
final now = DateTime.now();
if (_histY != null && _histTime != null) {
final dy = p.x - _histY!;
final dx = p.y - _histX!;
final dist = math.sqrt(dy * dy + dx * dx);
final dtSec = now.difference(_histTime!).inMilliseconds / 1000.0;
if (dtSec > 0) {
final v = dist / dtSec; // m/s
speedKmh.value = v * 3.6;
sessionDistanceM.value += dist;
// Irány csak akkor frissül, ha a sebesség a fagyasztási küszöb
// felett van — álló/nagyon lassú helyzetben a nyers irány zajos.
if (speedKmh.value >= _headingFreezeSpeedKmh && dist > 0.3) {
travelHeading.value =
(math.atan2(dy, dx) * 180 / math.pi + 360) % 360;
}
}
}
_histY = p.x;
_histX = p.y;
_histTime = now;
curEovY.value = p.x;
curEovX.value = p.y;
hasPosition.value = true;
_updateNearest();
}
void _updateNearest() {
if (sourcePoints.isEmpty) {
nearestPoint.value = null;
return;
}
SourcePoint? best;
var bestDist = double.infinity;
for (final sp in sourcePoints) {
final dy = sp.planEovY - curEovY.value;
final dx = sp.planEovX - curEovX.value;
final d = math.sqrt(dy * dy + dx * dx);
if (d < bestDist) {
bestDist = d;
best = sp;
}
}
nearestPoint.value = best;
nearestDistance.value = bestDist;
}
// ═════════════════════════════════════════════════════════════════
// Terv/napló összevetés — melyik forráspont "igazolt"
// ═════════════════════════════════════════════════════════════════
/// Egyszerű, szinkron ellenőrzés a már betöltött [logs] lista alapján
/// (a nézet előre lekéri, hogy ne fusson adatbázis-lekérdezés minden
/// egyes marker kirajzolásakor).
bool isVerified(SourcePoint sp, List<VehiclePositionLog> logs) {
for (final log in logs) {
final dy = sp.planEovY - log.eovY;
final dx = sp.planEovX - log.eovX;
if (math.sqrt(dy * dy + dx * dx) <= tolerance.value) return true;
}
return false;
}
Future<List<VehiclePositionLog>> loadLogs() async {
final projectId = ProjectService.to.activeProjectId;
if (projectId == null) return [];
return _db.listVehiclePositionLogs(projectId);
}
// ═════════════════════════════════════════════════════════════════
// Periodikus rögzítés
// ═════════════════════════════════════════════════════════════════
void startLogging() {
if (isLogging.value) return;
isLogging.value = true;
_logTick(); // azonnali első pont
_logTimer = Timer.periodic(
Duration(seconds: logIntervalSec.value), (_) => _logTick());
}
void stopLogging() {
isLogging.value = false;
_logTimer?.cancel();
_logTimer = null;
}
Future<void> _logTick() async {
if (!hasPosition.value) return;
final projectId = ProjectService.to.activeProjectId;
final vehicleId = VehicleIdentityService.to.selectedVehicle.value;
if (projectId == null || vehicleId == null) return;
final gnss = GnssService.to;
final entry = VehiclePositionLog(
projectId: projectId,
vehicleId: vehicleId,
eovY: curEovY.value,
eovX: curEovX.value,
lat: gnss.latitude.value,
lon: gnss.longitude.value,
altitude: gnss.altitude.value,
speedKmh: speedKmh.value,
heading: travelHeading.value,
fixQuality: gnss.gpsQuality.value,
accuracy: gnss.horizontalAccuracy,
deviceId: Get.isRegistered<DeviceIdentityService>() &&
DeviceIdentityService.to.isReady
? DeviceIdentityService.to.deviceId
: null,
appInstanceId: Get.isRegistered<DeviceIdentityService>() &&
DeviceIdentityService.to.isReady
? DeviceIdentityService.to.appInstanceId
: null,
);
await _db.insertVehiclePositionLog(entry);
}
}
@@ -0,0 +1,456 @@
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:get/get.dart';
import 'package:latlong2/latlong.dart';
import 'package:terepi_seged/models/source_point.dart';
import 'package:terepi_seged/models/vechicle_position_log.dart';
import 'package:terepi_seged/services/app_database.dart';
import 'package:terepi_seged/services/coord_converter_service.dart';
import 'package:terepi_seged/services/gnss/gnss_service.dart';
import 'package:terepi_seged/services/project_service.dart';
import 'package:terepi_seged/services/sps_import_service.dart';
import 'package:terepi_seged/services/vechicle_identity_service.dart';
import 'package:terepi_seged/widgets/map/imported_layer_overlay.dart';
import '../controllers/vibro_nav_controller.dart';
/// Egyszerű navigációs oldal a vibrátor-járműhöz. ÖNÁLLÓ oldal, nem
/// MapSurveyMode — saját MapController kell a forgó (track-up)
/// térképhez, hogy ez semmilyen más módot ne érintsen.
class VibroNavView extends StatefulWidget {
const VibroNavView({super.key});
@override
State<VibroNavView> createState() => _VibroNavViewState();
}
class _VibroNavViewState extends State<VibroNavView> {
late final VibroNavController controller;
final _mapController = MapController();
List<VehiclePositionLog> _logs = [];
@override
void initState() {
super.initState();
controller = Get.put(VibroNavController());
_refreshLogs();
// Forgatás + a naplók frissítése a pozíció-változásokra.
ever(controller.travelHeading, (_) => _applyRotation());
ever(controller.hasPosition, (_) => _followPosition());
}
@override
void dispose() {
Get.delete<VibroNavController>();
super.dispose();
}
Future<void> _refreshLogs() async {
_logs = await controller.loadLogs();
if (mounted) setState(() {});
}
void _applyRotation() {
final h = controller.travelHeading.value;
if (h == null) return;
try {
_mapController.rotate(-h);
} catch (_) {}
}
void _followPosition() {
if (!controller.hasPosition.value ||
!Get.isRegistered<CoordConverterService>()) {
return;
}
final gnss = GnssService.to;
try {
_mapController.move(LatLng(gnss.latitude.value, gnss.longitude.value),
_mapController.camera.zoom);
} catch (_) {}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Vibrátor navigáció'),
actions: [
IconButton(
icon: const Icon(Icons.upload_file),
tooltip: 'Forráspontok importja (SPS S-fájl)',
onPressed: _importSourcePoints,
),
IconButton(
icon: const Icon(Icons.local_shipping_outlined),
tooltip: 'Jármű kiválasztása',
onPressed: _pickVehicle,
),
],
),
body: Stack(
children: [
_buildMap(),
Positioned(
left: 8,
right: 8,
top: 8,
child: _VehicleBadge(),
),
Positioned(
left: 8,
right: 8,
bottom: 8,
child: _NavPanel(
controller: controller, onLoggingToggled: _refreshLogs),
),
],
),
);
}
Widget _buildMap() {
return Obx(() {
final points = controller.sourcePoints;
final nearest = controller.nearestPoint.value;
return FlutterMap(
mapController: _mapController,
options: const MapOptions(
initialCenter: LatLng(47.5, 19.05),
initialZoom: 15,
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'hu.app_dev.terepi_seged',
),
// Tervezett útvonal + minden más importált háttérréteg — a
// MEGLÉVŐ rétegimport-mechanizmus, nincs hozzá új kód.
const ImportedLayerOverlay(),
MarkerLayer(markers: [
for (final sp in points)
Marker(
point: LatLng(sp.planLat, sp.planLon),
width: 40,
height: 40,
child: _SourcePointMarker(
point: sp,
isNearest: sp.uuid == nearest?.uuid,
isVerified: controller.isVerified(sp, _logs),
),
),
if (controller.hasPosition.value)
Marker(
point: LatLng(GnssService.to.latitude.value,
GnssService.to.longitude.value),
width: 34,
height: 34,
child: const _VehicleMarker(),
),
]),
],
);
});
}
// ── Jármű-választás ────────────────────────────────────────────────
Future<void> _pickVehicle() async {
final chosen = await Get.dialog<String>(AlertDialog(
title: const Text('Melyik járműben van ez a tablet?'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final v in VehicleIdentityService.availableVehicles)
RadioListTile<String>(
title: Text(v),
value: v,
groupValue: VehicleIdentityService.to.selectedVehicle.value,
onChanged: (val) => Get.back(result: val),
),
],
),
));
if (chosen != null) {
await VehicleIdentityService.to.setVehicle(chosen);
}
}
// ── SPS S-fájl (forráspont) import — kompakt, egyetlen dialógus ────
Future<void> _importSourcePoints() async {
final result = await FilePicker.platform.pickFiles(type: FileType.any);
final path = result?.files.single.path;
if (path == null) return;
final projectId = ProjectService.to.activeProjectId;
if (projectId == null) return;
try {
final content = await File(path).readAsString();
final points = SpsParser.parseSourceFile(content);
if (points.isEmpty) {
Get.snackbar(
'Import',
'Nem sikerült forráspontot beolvasni ebből a fájlból — '
'ellenőrizd, hogy valódi SPS S-fájl-e.',
snackPosition: SnackPosition.BOTTOM);
return;
}
final withPos = points.where((p) => p.eovY != null && p.eovX != null);
final ok = await Get.dialog<bool>(AlertDialog(
title: const Text('Forráspontok importja'),
content: Text('${points.length} pont az S-fájlban, '
'${withPos.length} érvényes koordinátával.\n\n'
'Első néhány: ${points.take(3).map((p) => '${p.lineId}·${p.station}').join(', ')}'),
actions: [
TextButton(
onPressed: () => Get.back(result: false),
child: const Text('Mégse')),
FilledButton(
onPressed: () => Get.back(result: true),
child: const Text('Import')),
],
));
if (ok != true) return;
final conv = CoordConverterService.to;
final batch = DateTime.now().toIso8601String();
final saved = <SourcePoint>[];
for (final p in withPos) {
final w = conv.eovToWgsPoint(p.eovY!, p.eovX!);
saved.add(SourcePoint(
projectId: projectId,
lineId: p.lineId,
station: p.station,
planEovY: p.eovY!,
planEovX: p.eovX!,
planLat: w.y,
planLon: w.x,
importBatch: batch,
));
}
final inserted = await AppDatabase.instance.insertSourcePoints(saved);
await controller.load();
Get.snackbar('Import kész', '$inserted forráspont importálva',
snackPosition: SnackPosition.BOTTOM);
} catch (e) {
Get.snackbar('Import hiba', e.toString(),
snackPosition: SnackPosition.BOTTOM);
}
}
}
// ═══════════════════════════════════════════════════════════════════
// HUD panel: sebesség, táv, legközelebbi pont, rögzítés vezérlés
// ═══════════════════════════════════════════════════════════════════
class _NavPanel extends StatelessWidget {
final VibroNavController controller;
final VoidCallback onLoggingToggled;
const _NavPanel({required this.controller, required this.onLoggingToggled});
@override
Widget build(BuildContext context) {
return Obx(() {
final nearest = controller.nearestPoint.value;
return Card(
elevation: 6,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_Stat(
label: 'Sebesség',
value:
'${controller.speedKmh.value.toStringAsFixed(0)} km/h'),
_Stat(
label: 'Megtett táv',
value: _fmtDist(controller.sessionDistanceM.value)),
if (nearest != null)
_Stat(
label: 'Legközelebbi VP',
value: nearest.displayId,
highlight: true),
if (nearest != null)
_Stat(
label: 'Táv odáig',
value: _fmtDist(controller.nearestDistance.value)),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: controller.isLogging.value
? OutlinedButton.icon(
icon: const Icon(Icons.stop_circle_outlined),
label: Text('Rögzítés leállítása '
'(${controller.logIntervalSec.value} mp)'),
onPressed: () {
controller.stopLogging();
onLoggingToggled();
},
)
: FilledButton.icon(
icon: const Icon(Icons.fiber_manual_record,
color: Colors.red),
label: Text('Rögzítés indítása '
'(${controller.logIntervalSec.value} mp-enként)'),
onPressed: () {
controller.startLogging();
onLoggingToggled();
},
),
),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.timer_outlined),
tooltip: 'Rögzítési időköz',
onPressed: controller.isLogging.value
? null
: () => _pickInterval(controller),
),
],
),
],
),
),
);
});
}
Future<void> _pickInterval(VibroNavController c) async {
final opts = [15, 30, 60, 120, 300];
final chosen = await Get.dialog<int>(AlertDialog(
title: const Text('Rögzítési időköz'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final s in opts)
RadioListTile<int>(
title: Text(s < 60 ? '$s másodperc' : '${s ~/ 60} perc'),
value: s,
groupValue: c.logIntervalSec.value,
onChanged: (v) => Get.back(result: v),
),
],
),
));
if (chosen != null) c.logIntervalSec.value = chosen;
}
static String _fmtDist(double m) => m < 1000
? '${m.toStringAsFixed(0)} m'
: '${(m / 1000).toStringAsFixed(2)} km';
}
class _Stat extends StatelessWidget {
final String label;
final String value;
final bool highlight;
const _Stat(
{required this.label, required this.value, this.highlight = false});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(value,
style: TextStyle(
fontSize: highlight ? 16 : 18,
fontWeight: FontWeight.w700,
color:
highlight ? Theme.of(context).colorScheme.primary : null)),
Text(label,
style: TextStyle(fontSize: 10, color: Colors.grey.shade600)),
],
);
}
}
// ═══════════════════════════════════════════════════════════════════
class _VehicleBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Obx(() {
final v = VehicleIdentityService.to.selectedVehicle.value;
return Align(
alignment: Alignment.topLeft,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: v == null
? Colors.orange.withOpacity(0.9)
: Colors.black.withOpacity(0.7),
borderRadius: BorderRadius.circular(8),
),
child: Text(
v == null ? 'Nincs jármű kiválasztva!' : 'Jármű: $v',
style: const TextStyle(color: Colors.white, fontSize: 12),
),
),
);
});
}
}
class _SourcePointMarker extends StatelessWidget {
final SourcePoint point;
final bool isNearest;
final bool isVerified;
const _SourcePointMarker(
{required this.point, required this.isNearest, required this.isVerified});
@override
Widget build(BuildContext context) {
final color = isVerified ? Colors.green : Colors.grey.shade600;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
isVerified ? Icons.check_circle : Icons.radio_button_unchecked,
color: isNearest ? Colors.red : color,
size: isNearest ? 26 : 18,
),
Text(point.displayId,
style: TextStyle(
fontSize: 9,
fontWeight: isNearest ? FontWeight.w700 : FontWeight.w400,
color: isNearest ? Colors.red : Colors.black87)),
],
);
}
}
class _VehicleMarker extends StatelessWidget {
const _VehicleMarker();
@override
Widget build(BuildContext context) {
// Track-up módban a szimbólum mindig "felfelé" mutat — a világ
// forog körülötte, nem a szimbólum a világ körül.
return Container(
decoration: BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(color: Colors.blue.withOpacity(0.5), blurRadius: 8)
],
),
child: const Icon(Icons.navigation, color: Colors.white, size: 18),
);
}
}