Navigációs képernyőn forráspontok megjelenítése
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 9s
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 9s
This commit is contained in:
@@ -21,6 +21,7 @@ import 'package:terepi_seged/services/gnss/gnss_device_service.dart';
|
||||
import 'package:terepi_seged/services/gnss/gnss_service.dart';
|
||||
import 'package:terepi_seged/services/layer_import_service.dart';
|
||||
import 'package:terepi_seged/services/layer_sync_service.dart';
|
||||
import 'package:terepi_seged/services/measure_source_point_service.dart';
|
||||
import 'package:terepi_seged/services/measured_data_service.dart';
|
||||
import 'package:terepi_seged/services/note_audio_service.dart';
|
||||
import 'package:terepi_seged/services/note_photo_service.dart';
|
||||
@@ -119,6 +120,7 @@ Future<void> main() async {
|
||||
Get.put(FieldPropertyService());
|
||||
Get.put(ParcelGeometryService());
|
||||
Get.put(MeasuredDataService());
|
||||
Get.put(MeasureSourcePointService());
|
||||
|
||||
runApp(const MyApp());
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/// Egy jelgerjesztési/tartalék forráspont a `terepi_seged_measure_source_points`
|
||||
/// táblából. Tisztán online — nincs helyi tükör.
|
||||
class MeasureSourcePoint {
|
||||
final String id;
|
||||
final String projectId;
|
||||
final double lineNumber;
|
||||
final double pointNumber;
|
||||
final int sequenceIndex;
|
||||
final double eovX;
|
||||
final double eovY;
|
||||
final double? elevation;
|
||||
final double? linkedReceiverLineNumber;
|
||||
|
||||
/// 0 = tartalék pont, 1 = jelgerjesztés helye.
|
||||
final int planState;
|
||||
final DateTime? plannedAcquisitionDate;
|
||||
final int? fieldSequence;
|
||||
final String? sourceType;
|
||||
final bool isActive;
|
||||
|
||||
const MeasureSourcePoint({
|
||||
required this.id,
|
||||
required this.projectId,
|
||||
required this.lineNumber,
|
||||
required this.pointNumber,
|
||||
required this.sequenceIndex,
|
||||
required this.eovX,
|
||||
required this.eovY,
|
||||
this.elevation,
|
||||
this.linkedReceiverLineNumber,
|
||||
required this.planState,
|
||||
this.plannedAcquisitionDate,
|
||||
this.fieldSequence,
|
||||
this.sourceType,
|
||||
required this.isActive,
|
||||
});
|
||||
|
||||
bool get isReserve => planState == 0;
|
||||
bool get isShotLocation => planState == 1;
|
||||
|
||||
factory MeasureSourcePoint.fromMap(Map<String, dynamic> m) =>
|
||||
MeasureSourcePoint(
|
||||
id: m['id'] as String,
|
||||
projectId: m['project_id'] as String,
|
||||
lineNumber: (m['line_number'] as num).toDouble(),
|
||||
pointNumber: (m['point_number'] as num).toDouble(),
|
||||
sequenceIndex: m['sequence_index'] as int,
|
||||
|
||||
// FIGYELEM: a terepi_seged_measure_source_points táblában az
|
||||
// eov_x/eov_y oszlopnevek FEL VANNAK CSERÉLVE a valódi magyar
|
||||
// EOV-konvencióhoz képest (az "eov_x" oszlop tartalmazza a
|
||||
// ténylegesen Y-nak/keletinek számító, ~640 000 körüli
|
||||
// értéket, az "eov_y" a ténylegesen X-nek/északinak számítót,
|
||||
// ~185 000 körülit) — itt kompenzáljuk, hogy a modell mezői
|
||||
// (eovX/eovY) a kód többi részével KONZISZTENS, valódi EOV-
|
||||
// jelentést hordozzanak.
|
||||
eovX: (m['eov_y'] as num).toDouble(),
|
||||
eovY: (m['eov_x'] as num).toDouble(),
|
||||
elevation: (m['elevation'] as num?)?.toDouble(),
|
||||
linkedReceiverLineNumber:
|
||||
(m['linked_receiver_line_number'] as num?)?.toDouble(),
|
||||
planState: m['plan_state'] as int,
|
||||
plannedAcquisitionDate: m['planned_acquisition_date'] != null
|
||||
? DateTime.parse(m['planned_acquisition_date'] as String)
|
||||
: null,
|
||||
fieldSequence: m['field_sequence'] as int?,
|
||||
sourceType: m['source_type'] as String?,
|
||||
isActive: m['is_active'] as bool,
|
||||
);
|
||||
}
|
||||
@@ -1,119 +1,155 @@
|
||||
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: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: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/device_identity_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';
|
||||
|
||||
/// Egyszerű navigációs panel a jelgerjesztő (vibrátor) járműhöz.
|
||||
/// 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.
|
||||
///
|
||||
/// 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).
|
||||
/// 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 {
|
||||
AppDatabase get _db => AppDatabase.instance;
|
||||
static VibroNavController get to => Get.find();
|
||||
|
||||
// ── Forráspontok ─────────────────────────────────────────────────
|
||||
final sourcePoints = <SourcePoint>[].obs;
|
||||
static const _gpsTypeExternal = 1;
|
||||
static const _gpsTypeInternal = 2;
|
||||
|
||||
/// Egyezés-tűrés (m) a naplózott pozíció és a terv-forráspont között.
|
||||
final tolerance = 15.0.obs;
|
||||
static const _trailMaxPoints = 300;
|
||||
static const _trailMinDistM = 3.0;
|
||||
|
||||
// ── Élő pozíció (a GnssService-ből, EOV-ra konvertálva) ───────────
|
||||
final mapController = MapController();
|
||||
final mapReady = false.obs;
|
||||
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;
|
||||
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;
|
||||
|
||||
// ── Legközelebbi forráspont ───────────────────────────────────────
|
||||
final nearestPoint = Rxn<SourcePoint>();
|
||||
final nearestDistance = 0.0.obs;
|
||||
|
||||
// ── Periodikus napló ───────────────────────────────────────────────
|
||||
final isLogging = false.obs;
|
||||
final logIntervalSec = 5.obs; // felhasználó által állítható
|
||||
final logs = <VehiclePositionLog>[].obs;
|
||||
Timer? _logTimer;
|
||||
|
||||
// ── Térkép — a MapController itt él, nem a View State-jében ────────
|
||||
final mapController = MapController();
|
||||
|
||||
StreamSubscription? _gnssUpdateSub;
|
||||
|
||||
/// A kezdő térképközéppont lekérdezése (gyors, telefon-GPS-alapú)
|
||||
/// befejeződött-e — a View ez alapján dönti el, mikor építse fel a
|
||||
/// térképet, hogy ne induljon rossz (pl. budapesti) középponttal.
|
||||
final mapReady = false.obs;
|
||||
static const _headingFreezeSpeedKmh = 5.0;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
load();
|
||||
refreshLogs();
|
||||
|
||||
watchSourcePoints();
|
||||
ever(ProjectService.to.activeProject, (_) => watchSourcePoints());
|
||||
|
||||
if (Get.isRegistered<GnssService>()) {
|
||||
// FONTOS: a lastGgaLine csak NMEA (külső BT/BLE vevő) esetén
|
||||
// frissül — telefon GPS-nél sosem. Az onDataUpdated viszont
|
||||
// MINDKÉT forrásnál lefut, ez a helyes, egységes jelzés.
|
||||
_gnssUpdateSub =
|
||||
GnssService.to.onDataUpdated.listen((_) => _onPosition());
|
||||
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;
|
||||
}
|
||||
// Automatikus indítás: amint van pozíció ÉS ki van választva jármű,
|
||||
// induljon a rögzítés — korábban ez a bekötés hiányzott, a
|
||||
// _tryAutoStart() metódust semmi nem hívta meg.
|
||||
ever(hasPosition, (_) => _tryAutoStart());
|
||||
ever(VehicleIdentityService.to.selectedVehicle, (_) => _tryAutoStart());
|
||||
|
||||
// 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() {
|
||||
_logTimer?.cancel();
|
||||
_gnssUpdateSub?.cancel();
|
||||
_sourcePointsSub?.cancel();
|
||||
_externalUploadTimer?.cancel();
|
||||
_internalUploadTimer?.cancel();
|
||||
_internalPositionSub?.cancel();
|
||||
_internalGps.disconnect().whenComplete(_internalGps.dispose);
|
||||
super.onClose();
|
||||
}
|
||||
|
||||
Future<void> refreshLogs() async {
|
||||
logs.value = await loadLogs();
|
||||
void resumeFollowing() {
|
||||
followMode.value = true;
|
||||
}
|
||||
|
||||
Future<void> load() async {
|
||||
final projectId = ProjectService.to.activeProjectId;
|
||||
if (projectId == null) {
|
||||
sourcePoints.clear();
|
||||
return;
|
||||
}
|
||||
sourcePoints.value = await _db.listSourcePoints(projectId);
|
||||
void toggleTrackUp() {
|
||||
trackUpEnabled.value = !trackUpEnabled.value;
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
// Pozíció-feldolgozás
|
||||
// Külső (fő) pozíció
|
||||
// ═════════════════════════════════════════════════════════════════
|
||||
|
||||
void _onPosition() {
|
||||
void _onExternalPosition() {
|
||||
final gnss = GnssService.to;
|
||||
if (gnss.gpsQuality.value <= 0 ||
|
||||
gnss.latitude.value == 0 ||
|
||||
@@ -123,6 +159,7 @@ class VibroNavController extends GetxController {
|
||||
|
||||
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) {
|
||||
@@ -130,128 +167,194 @@ class VibroNavController extends GetxController {
|
||||
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) {
|
||||
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;
|
||||
|
||||
curEovY.value = p.x;
|
||||
curEovX.value = p.y;
|
||||
hasPosition.value = true;
|
||||
|
||||
_updateNearest();
|
||||
_addTrailPoint(LatLng(gnss.latitude.value, gnss.longitude.value));
|
||||
}
|
||||
|
||||
void _updateNearest() {
|
||||
if (sourcePoints.isEmpty) {
|
||||
nearestPoint.value = null;
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
void _tryAutoStart() {
|
||||
if (isLogging.value) return;
|
||||
if (!hasPosition.value) return;
|
||||
if (VehicleIdentityService.to.selectedVehicle.value == null) return;
|
||||
startLogging();
|
||||
}
|
||||
|
||||
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,
|
||||
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');
|
||||
},
|
||||
);
|
||||
await _db.insertVehiclePositionLog(entry);
|
||||
logs.add(entry);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -85,7 +85,9 @@ class PhoneGpsConnection implements GnssConnection {
|
||||
Future<void> disconnect() async {
|
||||
_retryTimer?.cancel();
|
||||
await _positionSub?.cancel();
|
||||
_stateController.add(GnssConnectionState.disconnected);
|
||||
if (!_stateController.isClosed) {
|
||||
_stateController.add(GnssConnectionState.disconnected);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:get/get.dart';
|
||||
import 'package:supabase_flutter/supabase_flutter.dart';
|
||||
|
||||
import '../models/measure_source_point.dart';
|
||||
|
||||
class SourcePointFetchResult {
|
||||
final List<MeasureSourcePoint> points;
|
||||
final bool capped;
|
||||
const SourcePointFetchResult({required this.points, required this.capped});
|
||||
}
|
||||
|
||||
/// Forráspontok (jelgerjesztés/tartalék) lekérdezése a navigációs
|
||||
/// térképhez. SZÁNDÉKOSAN tisztán online — nincs helyi tükör.
|
||||
class MeasureSourcePointService extends GetxService {
|
||||
static MeasureSourcePointService get to => Get.find();
|
||||
|
||||
SupabaseClient get _client => Supabase.instance.client;
|
||||
|
||||
/// Ha egy pontnak VAN megadott tervezett dátuma, csak a mait mutatjuk;
|
||||
/// aminek NINCS dátuma, azt mindig — plusz egy kemény felső korlát,
|
||||
/// nehogy egy dátum nélküli projektnél ezerszámra terhelje a térképet.
|
||||
static const _safetyLimit = 6000;
|
||||
|
||||
/// Élő frissítés a stream() helyett: a tényleges adatot SZERVER-oldali
|
||||
/// szűréssel, lapozva kérjük le (a PostgREST 1000 soros felső
|
||||
/// korlátja miatt a .stream() csonkított, rendezetlen részhalmazt
|
||||
/// adott), a realtime csatorna pedig csak jelez, hogy változott
|
||||
/// valami — ilyenkor újra lekérünk.
|
||||
Stream<SourcePointFetchResult> watchTodayOrUndated(String projectId) {
|
||||
late StreamController<SourcePointFetchResult> controller;
|
||||
RealtimeChannel? channel;
|
||||
Timer? debounce;
|
||||
var disposed = false;
|
||||
|
||||
Future<void> refetch() async {
|
||||
if (disposed) return;
|
||||
try {
|
||||
final result = await _fetchAll(projectId);
|
||||
if (!disposed) controller.add(result);
|
||||
} catch (e, s) {
|
||||
if (!disposed) controller.addError(e, s);
|
||||
}
|
||||
}
|
||||
|
||||
controller = StreamController<SourcePointFetchResult>(
|
||||
onListen: () {
|
||||
refetch();
|
||||
channel = _client
|
||||
.channel('source_points_$projectId')
|
||||
.onPostgresChanges(
|
||||
event: PostgresChangeEvent.all,
|
||||
schema: 'public',
|
||||
table: 'terepi_seged_measure_source_points',
|
||||
filter: PostgresChangeFilter(
|
||||
type: PostgresChangeFilterType.eq,
|
||||
column: 'project_id',
|
||||
value: projectId,
|
||||
),
|
||||
callback: (_) {
|
||||
// Sok egyidejű változásnál (pl. tömeges import) ne
|
||||
// kérjünk le minden egyes sorra újra.
|
||||
debounce?.cancel();
|
||||
debounce = Timer(const Duration(milliseconds: 600), refetch);
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
},
|
||||
onCancel: () async {
|
||||
disposed = true;
|
||||
debounce?.cancel();
|
||||
if (channel != null) await _client.removeChannel(channel!);
|
||||
},
|
||||
);
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
Future<SourcePointFetchResult> _fetchAll(String projectId) async {
|
||||
final now = DateTime.now();
|
||||
final todayStr = '${now.year.toString().padLeft(4, '0')}-'
|
||||
'${now.month.toString().padLeft(2, '0')}-'
|
||||
'${now.day.toString().padLeft(2, '0')}';
|
||||
|
||||
const pageSize = 1000;
|
||||
final all = <MeasureSourcePoint>[];
|
||||
var from = 0;
|
||||
while (all.length <= _safetyLimit) {
|
||||
final rows = await _client
|
||||
.from('terepi_seged_measure_source_points')
|
||||
.select()
|
||||
.eq('project_id', projectId)
|
||||
.eq('is_active', true)
|
||||
.or('planned_acquisition_date.eq.$todayStr,'
|
||||
'planned_acquisition_date.is.null')
|
||||
.order('line_number', ascending: true)
|
||||
.order('point_number', ascending: true)
|
||||
.range(from, from + pageSize - 1);
|
||||
all.addAll(rows.map((r) => MeasureSourcePoint.fromMap(r)));
|
||||
if (rows.length < pageSize) break;
|
||||
from += pageSize;
|
||||
}
|
||||
|
||||
final capped = all.length > _safetyLimit;
|
||||
if (capped) all.removeRange(_safetyLimit, all.length);
|
||||
return SourcePointFetchResult(points: all, capped: capped);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:get/get.dart';
|
||||
@@ -17,16 +18,92 @@ class MeasuredDataService extends GetxService {
|
||||
/// TODO: erősítsd meg a valós bucket-nevet, ha eltér ettől.
|
||||
static const _bucket = 'terepi_seged-measure-images';
|
||||
|
||||
/// Élő, folyamatosan frissülő stream — a Supabase Realtime-on keresztül,
|
||||
/// pontosan úgy, ahogy korábban a Firestore .snapshots() működött. Új
|
||||
/// felvétel a PC-szoftverből AZONNAL megjelenik, frissítés nélkül.
|
||||
static const _pageSize = 1000;
|
||||
|
||||
/// A legfrissebb ennyi felvételt töltjük be (a PostgREST 1000-es
|
||||
/// max-rows miatt lapozva).
|
||||
static const _maxRecords = 3000;
|
||||
|
||||
/// SZÁNDÉKOSAN nem `.stream()`: az rendezetlen, 1000 sorra vágott
|
||||
/// lekérdezést futtat, ezért sorok tűnnek el. Helyette szerveroldalon
|
||||
/// szűrt, lapozott lekérdezés + Realtime csatorna, ami csak jelzés
|
||||
/// (debounce-olt újratöltés).
|
||||
Stream<List<MeasuredRecord>> watchRecords(String projectId) {
|
||||
return _client
|
||||
.from('terepi_seged_measure_records')
|
||||
.stream(primaryKey: ['id'])
|
||||
.eq('project_id', projectId)
|
||||
.order('measured_at', ascending: false)
|
||||
.map((rows) => rows.map((r) => MeasuredRecord.fromMap(r)).toList());
|
||||
late StreamController<List<MeasuredRecord>> controller;
|
||||
RealtimeChannel? channel;
|
||||
Timer? debounce;
|
||||
var disposed = false;
|
||||
var fetching = false;
|
||||
var again = false;
|
||||
|
||||
Future<void> refetch() async {
|
||||
if (disposed) return;
|
||||
if (fetching) {
|
||||
again = true;
|
||||
return;
|
||||
}
|
||||
fetching = true;
|
||||
try {
|
||||
do {
|
||||
again = false;
|
||||
final list = await _fetchAll(projectId);
|
||||
if (!disposed) controller.add(list);
|
||||
} while (again && !disposed);
|
||||
} catch (e, s) {
|
||||
if (!disposed) controller.addError(e, s);
|
||||
} finally {
|
||||
fetching = false;
|
||||
}
|
||||
}
|
||||
|
||||
controller = StreamController<List<MeasuredRecord>>(
|
||||
onListen: () {
|
||||
refetch();
|
||||
channel = _client
|
||||
.channel('measure_records_$projectId')
|
||||
.onPostgresChanges(
|
||||
event: PostgresChangeEvent.all,
|
||||
schema: 'public',
|
||||
table: 'terepi_seged_measure_records',
|
||||
filter: PostgresChangeFilter(
|
||||
type: PostgresChangeFilterType.eq,
|
||||
column: 'project_id',
|
||||
value: projectId,
|
||||
),
|
||||
callback: (_) {
|
||||
debounce?.cancel();
|
||||
debounce = Timer(const Duration(milliseconds: 800), refetch);
|
||||
},
|
||||
)
|
||||
.subscribe();
|
||||
},
|
||||
onCancel: () async {
|
||||
disposed = true;
|
||||
debounce?.cancel();
|
||||
if (channel != null) await _client.removeChannel(channel!);
|
||||
},
|
||||
);
|
||||
return controller.stream;
|
||||
}
|
||||
|
||||
Future<List<MeasuredRecord>> _fetchAll(String projectId) async {
|
||||
final out = <MeasuredRecord>[];
|
||||
var from = 0;
|
||||
while (out.length < _maxRecords) {
|
||||
final rows = await _client
|
||||
.from('terepi_seged_measure_records')
|
||||
.select()
|
||||
.eq('project_id', projectId)
|
||||
.order('measured_at', ascending: false)
|
||||
.order('id', ascending: false)
|
||||
.range(from, from + _pageSize - 1);
|
||||
for (final r in rows) {
|
||||
out.add(MeasuredRecord.fromMap(r));
|
||||
}
|
||||
if (rows.length < _pageSize) break;
|
||||
from += _pageSize;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/// A felvételhez tartozó kép betöltése — elsőbbséget adva az image_url-nek
|
||||
|
||||
@@ -10,7 +10,7 @@ class VehicleIdentityService extends GetxService {
|
||||
static const _key = 'vehicle_id';
|
||||
|
||||
/// Az alapértelmezett választható lista — igény szerint bővíthető.
|
||||
static const availableVehicles = ['V1', 'V2', 'V3'];
|
||||
static const availableVehicles = ['V1', 'V2', 'V3', 'V4', 'Teszt'];
|
||||
|
||||
final selectedVehicle = Rxn<String>();
|
||||
|
||||
|
||||
@@ -42,9 +42,18 @@ class AnimatedMapFollow extends StatelessWidget {
|
||||
duration: duration,
|
||||
curve: Curves.easeOut,
|
||||
builder: (context, animatedPos, _) {
|
||||
try {
|
||||
mapController.move(animatedPos, mapController.camera.zoom);
|
||||
} catch (_) {}
|
||||
// NE hívjuk a .move()-ot közvetlenül itt — ez a builder BUILD
|
||||
// közben fut, a mapController.move() pedig a flutter_map
|
||||
// belső gesztus-kezelő widgetjén (MapInteractiveViewer) vált
|
||||
// ki setState()-et, ami "setState() called during build"
|
||||
// hibát ad. A jelenlegi képkocka VÉGÉRE halasztjuk, ahol ez
|
||||
// már biztonságos — ugyanaz az elv, mint az
|
||||
// onPositionChanged-nél.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
try {
|
||||
mapController.move(animatedPos, mapController.camera.zoom);
|
||||
} catch (_) {}
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
@@ -55,9 +64,11 @@ class AnimatedMapFollow extends StatelessWidget {
|
||||
duration: duration,
|
||||
curve: Curves.easeOut,
|
||||
builder: (context, animatedRotation, _) {
|
||||
try {
|
||||
mapController.rotate(animatedRotation);
|
||||
} catch (_) {}
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
try {
|
||||
mapController.rotate(animatedRotation);
|
||||
} catch (_) {}
|
||||
});
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user