Files
MobilApp/lib/pages/vibro_nav/presentation/controllers/vibro_nav_controller.dart
T
gitea_admin e3ff7d070c
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 9s
Navigációs képernyőn forráspontok megjelenítése
2026-10-04 20:18:08 +02:00

361 lines
13 KiB
Dart

import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:geolocator/geolocator.dart' as geo;
import 'package:get/get.dart';
import 'package:latlong2/latlong.dart';
import 'package:supabase_flutter/supabase_flutter.dart';
import 'package:terepi_seged/models/measure_source_point.dart';
import 'package:terepi_seged/services/app_logger.dart';
import 'package:terepi_seged/services/coord_converter_service.dart';
import 'package:terepi_seged/services/gnss/gnss_connection.dart';
import 'package:terepi_seged/services/gnss/gnss_service.dart';
import 'package:terepi_seged/services/gnss/phone_gps_connection.dart';
import 'package:terepi_seged/services/measure_source_point_service.dart';
import 'package:terepi_seged/services/project_service.dart';
import 'package:terepi_seged/services/vechicle_identity_service.dart';
/// Forráspont + egyszer kiszámolt WGS84 pozíció. A térkép ebből rajzol,
/// így GNSS-frissítéskor nincs újraszámolás.
class GeoSourcePoint {
final MeasureSourcePoint point;
final LatLng latLng;
const GeoSourcePoint(this.point, this.latLng);
}
/// Navigációs képernyő — a KÜLSŐ (Bluetooth/BLE RTK-vevő, vagy a telefon
/// GPS-e, amit a GnssService-eszközválasztóval választasz ki) a FŐ
/// pozíció. EMELLETT, attól teljesen FÜGGETLENÜL, mindig fut egy saját,
/// belső telefon-GPS kapcsolat is — akkor is naplózódik, ha épp külső
/// vevő az aktív forrás.
///
/// Mindkét adatfolyam a KÖZÖS, PC-szoftverrel is megosztott
/// `TerepiSeged_VibratorTracker` táblába ír, a `gpsType` mezővel
/// megkülönböztetve (1 = külső/fő forrás, 2 = belső telefon-GPS).
class VibroNavController extends GetxController {
static VibroNavController get to => Get.find();
static const _gpsTypeExternal = 1;
static const _gpsTypeInternal = 2;
static const _trailMaxPoints = 300;
static const _trailMinDistM = 3.0;
final mapController = MapController();
final mapReady = false.obs;
final hasPosition = false.obs;
final travelHeading = Rxn<double>();
final speedKmh = 0.0.obs;
/// Ha true, a térkép automatikusan követi a pozíciót. Amint a
/// felhasználó kézzel elhúzza/nagyítja a térképet, false-ra vált — a
/// nézet ilyenkor "ott marad", és egy gomb hozza vissza a követést.
final followMode = true.obs;
/// Track-up: a térkép a haladási irány szerint forogjon, ne mindig
/// észak legyen fent. Kikapcsolható, ha inkább a hagyományos,
/// észak-fent nézetet szeretnéd.
final trackUpEnabled = true.obs;
final trail = <LatLng>[].obs;
final sourcePoints = <MeasureSourcePoint>[].obs;
final geoSourcePoints = <GeoSourcePoint>[].obs;
final Map<String, LatLng?> _geoCache = {};
final isLoadingSourcePoints = false.obs;
/// Ha false, a tartalék (plan_state = 0) pontok nem jelennek meg a
/// térképen — csak a jelgerjesztés helyei (plan_state = 1) látszanak.
final showReservePoints = false.obs;
final lastExternalUploadAt = Rxn<DateTime>();
final lastInternalUploadAt = Rxn<DateTime>();
final externalUploadPulse = false.obs;
final internalUploadPulse = false.obs;
StreamSubscription? _gnssUpdateSub;
Timer? _externalUploadTimer;
final _internalGps = PhoneGpsConnection();
StreamSubscription<geo.Position>? _internalPositionSub;
StreamSubscription<SourcePointFetchResult>? _sourcePointsSub;
geo.Position? _lastInternalPosition;
Timer? _internalUploadTimer;
double? _histY, _histX;
DateTime? _histTime;
static const _headingFreezeSpeedKmh = 5.0;
@override
void onInit() {
super.onInit();
watchSourcePoints();
ever(ProjectService.to.activeProject, (_) => watchSourcePoints());
if (Get.isRegistered<GnssService>()) {
_gnssUpdateSub =
GnssService.to.onDataUpdated.listen((_) => _onExternalPosition());
GnssService.to.determineInitialPosition().whenComplete(() {
// A kezdő pozíció lekérése önmagában NEM vált ki onDataUpdated-et
// — ha lett érvényes koordináta, itt kell külön jeleznünk, hogy a
// térkép ne maradjon a "nincs pozíció" alapértéken.
final gnss = GnssService.to;
if (gnss.latitude.value != 0) {
hasPosition.value = true;
_addTrailPoint(LatLng(gnss.latitude.value, gnss.longitude.value));
}
mapReady.value = true;
});
} else {
mapReady.value = true;
}
// A kézi térkép-húzás felismerése mostantól a MapOptions
// onPositionChanged (hasGesture) callback-jén megy, közvetlenül a
// View-ban — az megbízhatóbb, mint a nyers mapEventStream-forrás
// találgatása volt.
_externalUploadTimer =
Timer.periodic(const Duration(seconds: 5), (_) => _uploadExternal());
_startInternalGps();
_internalUploadTimer =
Timer.periodic(const Duration(seconds: 10), (_) => _uploadInternal());
}
@override
void onClose() {
_gnssUpdateSub?.cancel();
_sourcePointsSub?.cancel();
_externalUploadTimer?.cancel();
_internalUploadTimer?.cancel();
_internalPositionSub?.cancel();
_internalGps.disconnect().whenComplete(_internalGps.dispose);
super.onClose();
}
void resumeFollowing() {
followMode.value = true;
}
void toggleTrackUp() {
trackUpEnabled.value = !trackUpEnabled.value;
}
// ═════════════════════════════════════════════════════════════════
// Külső (fő) pozíció
// ═════════════════════════════════════════════════════════════════
void _onExternalPosition() {
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);
if (!p.x.isFinite || !p.y.isFinite) return;
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) * 3.6;
speedKmh.value = v;
if (v >= _headingFreezeSpeedKmh && dist > 0.3) {
travelHeading.value =
(math.atan2(dy, dx) * 180 / math.pi + 360) % 360;
}
}
}
_histY = p.x;
_histX = p.y;
_histTime = now;
hasPosition.value = true;
_addTrailPoint(LatLng(gnss.latitude.value, gnss.longitude.value));
}
void _addTrailPoint(LatLng p) {
if (trail.isNotEmpty) {
final d = const Distance().as(LengthUnit.Meter, trail.last, p);
if (d < _trailMinDistM) return;
}
trail.add(p);
if (trail.length > _trailMaxPoints) trail.removeAt(0);
}
Future<void> _uploadExternal() async {
final gnss = GnssService.to;
if (!hasPosition.value) return;
final vehicleNumber =
_vehicleNumberFrom(VehicleIdentityService.to.selectedVehicle.value);
if (vehicleNumber == null) return;
try {
final p = Get.isRegistered<CoordConverterService>()
? CoordConverterService.to
.wgsToEovPoint(gnss.longitude.value, gnss.latitude.value)
: null;
await Supabase.instance.client
.from('TerepiSeged_VibratorTracker')
.insert({
'gpsType': _gpsTypeExternal,
'vibratorNumber': vehicleNumber,
'gpsQuality': gnss.gpsQuality.value,
'gpsDateTime': gnss.gpsDateTime.value.toUtc().toIso8601String(),
'latitude': gnss.latitude.value,
'longitude': gnss.longitude.value,
'altitude': gnss.altitude.value,
'eovY': p?.x,
'eovX': p?.y,
'horizontalError':
math.max(gnss.latitudeError.value, gnss.longitudeError.value),
'verticalError': gnss.altitudeError.value,
});
lastExternalUploadAt.value = DateTime.now();
_pulse(externalUploadPulse);
} catch (e) {
AppLogger.e(
'NavigationController', 'Külső GPS-pozíció feltöltési hiba: $e');
}
}
// ═════════════════════════════════════════════════════════════════
// Belső (telefon) GPS
// ═════════════════════════════════════════════════════════════════
Future<void> _startInternalGps() async {
_internalPositionSub = _internalGps.positionStream.listen((pos) {
_lastInternalPosition = pos;
});
await _internalGps.connect('internal');
}
Future<void> _uploadInternal() async {
final pos = _lastInternalPosition;
if (pos == null) return;
final vehicleNumber =
_vehicleNumberFrom(VehicleIdentityService.to.selectedVehicle.value);
if (vehicleNumber == null) return;
try {
final p = Get.isRegistered<CoordConverterService>()
? CoordConverterService.to.wgsToEovPoint(pos.longitude, pos.latitude)
: null;
await Supabase.instance.client
.from('TerepiSeged_VibratorTracker')
.insert({
'gpsType': _gpsTypeInternal,
'vibratorNumber': vehicleNumber,
'gpsQuality': 1,
'gpsDateTime': pos.timestamp.toUtc().toIso8601String(),
'latitude': pos.latitude,
'longitude': pos.longitude,
'altitude': pos.altitude,
'eovY': p?.x,
'eovX': p?.y,
'horizontalError': pos.accuracy,
'verticalError': pos.altitudeAccuracy,
});
lastInternalUploadAt.value = DateTime.now();
_pulse(internalUploadPulse);
} catch (e) {
AppLogger.e('NavigationController',
'Belső (telefon) GPS-pozíció feltöltési hiba: $e');
}
}
// ═════════════════════════════════════════════════════════════════
// Segéd
// ═════════════════════════════════════════════════════════════════
void _pulse(RxBool flag) {
flag.value = true;
Future.delayed(const Duration(milliseconds: 900), () => flag.value = false);
}
/// A "Teszt" egy jól elkülöníthető, a valódi 1-4-es járművekkel SOHA
/// nem ütköző azonosítót kap (99) — így tesztelés közben is végigfut
/// a teljes feltöltési lánc, csak a PC-szoftver oldalán egyértelműen
/// megkülönböztethető a valódi terepi adatoktól.
static const testVehicleNumber = 99;
int? _vehicleNumberFrom(String? vehicleId) {
if (vehicleId == null) return null;
if (vehicleId == 'Teszt') return testVehicleNumber;
final digits = vehicleId.replaceAll(RegExp(r'[^0-9]'), '');
return int.tryParse(digits);
}
void watchSourcePoints() {
_sourcePointsSub?.cancel();
final projectId = ProjectService.to.activeProject.value?.uuid;
if (projectId == null) {
sourcePoints.clear();
geoSourcePoints.clear();
return;
}
isLoadingSourcePoints.value = true;
_sourcePointsSub =
MeasureSourcePointService.to.watchTodayOrUndated(projectId).listen(
(result) {
sourcePoints.value = result.points;
geoSourcePoints.value = _toGeo(result.points);
isLoadingSourcePoints.value = false;
if (result.capped) {
Get.snackbar(
'Sok forráspont',
'A megjelenített pontok száma korlátozva lett — érdemes '
'kitölteni a tervezett dátumot a projektben.',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 5),
);
}
},
onError: (e) {
isLoadingSourcePoints.value = false;
AppLogger.e('NavigationController', 'Forráspont-stream hiba: $e');
},
);
}
void toggleReservePoints() {
showReservePoints.value = !showReservePoints.value;
}
/// Átváltás pontonként EGYSZER. A nem véges eredményeket eldobjuk,
/// mert a LatLng NaN összeomlasztja a kamerát.
List<GeoSourcePoint> _toGeo(List<MeasureSourcePoint> pts) {
if (!Get.isRegistered<CoordConverterService>()) return const [];
final conv = CoordConverterService.to;
final out = <GeoSourcePoint>[];
for (final p in pts) {
final key = '${p.eovX}_${p.eovY}';
final ll = _geoCache.putIfAbsent(key, () {
try {
final w = conv.eovToWgsPoint(p.eovY, p.eovX);
if (w.x.isFinite && w.y.isFinite) return LatLng(w.y, w.x);
} catch (_) {}
return null;
});
if (ll != null) out.add(GeoSourcePoint(p, ll));
}
return out;
}
List<GeoSourcePoint> get visibleGeoSourcePoints => showReservePoints.value
? geoSourcePoints
: geoSourcePoints.where((g) => g.point.isShotLocation).toList();
}