Track, nyomkövetés hozzáadása

This commit is contained in:
2026-05-10 02:31:27 +02:00
parent 810e118059
commit 7192fa5322
16 changed files with 1613 additions and 27 deletions
+76
View File
@@ -0,0 +1,76 @@
import 'dart:async';
import 'location_source.dart';
/// BLE GNSS vevőből érkező helymeghatározási forrás.
///
/// A meglévő Bluetooth + NMEA parsing logikát köti be a
/// [LocationSource] interfészbe, így a TrackingController
/// forrásváltás nélkül tud működni.
///
/// TEENDŐK a BLE verzió elkészültével:
/// 1. Injektáld a BluetoothConnection referenciát (vagy egy
/// stream-et a NMEA mondatokból).
/// 2. Parsold a GNGGA mondatokat (ugyanaz a [Gngga] osztály
/// ami a MapSurveyController-ben is megvan).
/// 3. Alkalmazd a geoid-korrekciót [GeoidGrid] segítségével.
/// 4. Töltsd fel a [SourcePosition]-t a korrekt mezőkkel.
class BleGnssSource implements LocationSource {
@override
String get displayName => 'BLE GNSS';
// TODO: Stream<String> nmeaStream — a BLE controller adja
final Stream<String>? nmeaStream;
StreamController<SourcePosition>? _controller;
StreamSubscription? _sub;
BleGnssSource({this.nmeaStream});
@override
bool get isAvailable => _sub != null;
@override
Stream<SourcePosition> get positionStream {
_controller = StreamController<SourcePosition>.broadcast();
if (nmeaStream == null) {
_controller!.addError(Exception(
'BLE GNSS forrás nincs bekötve. '
'Adj meg egy nmeaStream-et a konstruktorban.',
));
return _controller!.stream;
}
_sub = nmeaStream!.listen((line) {
if (!line.startsWith('\$GNGGA')) return;
// TODO: Gngga parser + GeoidGrid korreckció beépítése
// Példa váz:
//
// final sentence = nmeaDecoder.decode(line);
// if (sentence is! Gngga || !sentence.valid) return;
// final ellipsoidal =
// sentence.altitudeAboveMeanSeaLevel + sentence.geoidSeparation;
// final eovZ = geoidGrid.toEovHeight(
// sentence.latitude, sentence.longitude,
// sentence.altitudeAboveMeanSeaLevel, sentence.geoidSeparation);
//
// _controller?.add(SourcePosition(
// latitude: sentence.latitude,
// longitude: sentence.longitude,
// altitude: eovZ ?? ellipsoidal,
// accuracy: null, // GNGST-ből lehetne
// timestamp: DateTime.now(),
// source: displayName,
// ));
});
return _controller!.stream;
}
@override
Future<void> dispose() async {
await _sub?.cancel();
await _controller?.close();
}
}
+81
View File
@@ -0,0 +1,81 @@
import 'dart:io';
import 'package:path_provider/path_provider.dart';
import '../models/track.dart';
import 'track_database.dart';
/// GPX 1.1 fájl generáló.
/// A GPX az összes standard alkalmazással kompatibilis (OsmAnd, Komoot,
/// QGIS, gpsvisualizer.com stb.).
class GpxExporter {
final TrackDatabase _db;
GpxExporter([TrackDatabase? db]) : _db = db ?? TrackDatabase.instance;
/// Elkészíti a GPX fájlt és visszaadja az elérési utat.
Future<String> export(Track track) async {
final points = await _db.getPoints(track.id!);
final xml = _buildGpx(track, points);
final dir = await getExternalStorageDirectory() ??
await getApplicationDocumentsDirectory();
final safeName =
track.name.replaceAll(RegExp(r'[^a-zA-Z0-9_\-]'), '_').toLowerCase();
final file = File('${dir.path}/${safeName}_${track.id}.gpx');
await file.writeAsString(xml, encoding: utf8_encoding);
return file.path;
}
String _buildGpx(Track track, List<TrackPoint> points) {
final buf = StringBuffer();
buf.writeln('<?xml version="1.0" encoding="UTF-8"?>');
buf.writeln('<gpx version="1.1" creator="Terepi Segéd"');
buf.writeln(' xmlns="http://www.topografix.com/GPX/1/1"');
buf.writeln(' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"');
buf.writeln(' xsi:schemaLocation="http://www.topografix.com/GPX/1/1 '
'http://www.topografix.com/GPX/1/1/gpx.xsd">');
buf.writeln(' <metadata>');
buf.writeln(' <name>${_esc(track.name)}</name>');
buf.writeln(
' <time>${track.startTime.toUtc().toIso8601String()}</time>');
buf.writeln(' </metadata>');
buf.writeln(' <trk>');
buf.writeln(' <name>${_esc(track.name)}</name>');
buf.writeln(' <desc>Forrás: ${_esc(track.source)}, '
'${track.pointCount} pont, '
'${track.distanceFormatted}</desc>');
buf.writeln(' <trkseg>');
for (final pt in points) {
buf.write(' <trkpt lat="${pt.latitude}" lon="${pt.longitude}">');
if (pt.altitude != null) {
buf.write('<ele>${pt.altitude!.toStringAsFixed(3)}</ele>');
}
buf.write('<time>${pt.timestamp.toUtc().toIso8601String()}</time>');
if (pt.speed != null) {
buf.write('<speed>${pt.speed!.toStringAsFixed(2)}</speed>');
}
if (pt.heading != null) {
buf.write('<course>${pt.heading!.toStringAsFixed(1)}</course>');
}
if (pt.accuracy != null) {
buf.write('<hdop>${pt.accuracy!.toStringAsFixed(2)}</hdop>');
}
buf.writeln('</trkpt>');
}
buf.writeln(' </trkseg>');
buf.writeln(' </trk>');
buf.writeln('</gpx>');
return buf.toString();
}
String _esc(String s) => s
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
// ignore: non_constant_identifier_names
final utf8_encoding = const SystemEncoding();
+61
View File
@@ -0,0 +1,61 @@
import 'dart:async';
/// Egyetlen mért pozíció egységes reprezentációja.
/// Mindkét forrás (telefon GPS, BLE GNSS) ezt adja vissza.
class SourcePosition {
final double latitude;
final double longitude;
/// Ellipszoidi magasság [m] — telefonnál a platform adja,
/// BLE GNSS-nél a NMEA h = H + N értéke.
final double? altitude;
/// Vízszintes pontossági becslés [m] (1σ).
final double? accuracy;
/// Vertikális pontossági becslés [m].
final double? verticalAccuracy;
/// Pillanatnyi sebesség [m/s].
final double? speed;
/// Irányszög [fok, 0360, É=0].
final double? heading;
final DateTime timestamp;
/// Forrás azonosítója a naplókhoz.
final String source;
const SourcePosition({
required this.latitude,
required this.longitude,
this.altitude,
this.accuracy,
this.verticalAccuracy,
this.speed,
this.heading,
required this.timestamp,
required this.source,
});
@override
String toString() =>
'SourcePosition($source @ $latitude, $longitude, alt=${altitude?.toStringAsFixed(1)}m)';
}
/// Absztrakt helymeghatározási forrás.
/// Implementációk: [PhoneGpsSource], [BleGnssSource].
abstract class LocationSource {
/// Emberbarát név (pl. "Telefon GPS", "TiGNSS Rover").
String get displayName;
/// Elindítja a pozíció-streamet.
Stream<SourcePosition> get positionStream;
/// Igaz, ha a forrás jelenleg aktív / kapcsolódott.
bool get isAvailable;
/// Leállítja és felszabadítja az erőforrásokat.
Future<void> dispose();
}
+93
View File
@@ -0,0 +1,93 @@
import 'dart:async';
import 'package:geolocator/geolocator.dart';
import 'location_source.dart';
/// Telefon beépített GPS-ét használó helymeghatározási forrás.
///
/// Android háttér-működéshez a [flutter_foreground_task] kezeli
/// az előtér-szolgáltatást (notification), ez az osztály csak
/// a Geolocator streamet konfigurálja.
class PhoneGpsSource implements LocationSource {
@override
String get displayName => 'Telefon GPS';
StreamController<SourcePosition>? _controller;
StreamSubscription<Position>? _positionSub;
/// Frissítési intervallum ms-ban.
final int intervalMs;
/// Minimális elmozdulás méterben új pont előtt.
final double distanceFilter;
PhoneGpsSource({
this.intervalMs = 1000,
this.distanceFilter = 1.0,
});
@override
bool get isAvailable => _controller != null && !(_controller!.isClosed);
@override
Stream<SourcePosition> get positionStream {
_controller = StreamController<SourcePosition>.broadcast();
_startListening();
return _controller!.stream;
}
Future<void> _startListening() async {
// Engedélyek ellenőrzése
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.deniedForever ||
permission == LocationPermission.denied) {
_controller?.addError(
Exception('Helymeghatározási engedély megtagadva. '
'Kérjük, engedélyezze a beállításokban.'),
);
return;
}
final settings = AndroidSettings(
accuracy: LocationAccuracy.high,
distanceFilter: distanceFilter.toInt(),
intervalDuration: Duration(milliseconds: intervalMs),
// Háttér-helymeghatározáshoz szükséges — a foreground_task
// notification biztosítja a jogszerű háttér-használatot.
foregroundNotificationConfig: const ForegroundNotificationConfig(
notificationText: 'Track rögzítése folyamatban',
notificationTitle: 'Terepi Segéd Nyomvonal',
enableWakeLock: true,
),
);
_positionSub = Geolocator.getPositionStream(
locationSettings: settings,
).listen(
(Position pos) {
_controller?.add(SourcePosition(
latitude: pos.latitude,
longitude: pos.longitude,
altitude: pos.altitude,
accuracy: pos.accuracy,
verticalAccuracy: pos.altitudeAccuracy,
speed: pos.speed,
heading: pos.heading,
timestamp: pos.timestamp,
source: displayName,
));
},
onError: (e) => _controller?.addError(e),
);
}
@override
Future<void> dispose() async {
await _positionSub?.cancel();
await _controller?.close();
_controller = null;
}
}
+132
View File
@@ -0,0 +1,132 @@
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart' as p;
import '../models/track.dart';
/// SQLite adatbázis-réteg a nyomvonalakhoz.
/// Singleton — [TrackDatabase.instance]-on keresztül érhető el.
class TrackDatabase {
TrackDatabase._();
static final instance = TrackDatabase._();
static Database? _db;
Future<Database> get database async {
_db ??= await _open();
return _db!;
}
Future<Database> _open() async {
final dbPath = p.join(await getDatabasesPath(), 'tracks.db');
return openDatabase(
dbPath,
version: 1,
onCreate: _onCreate,
);
}
Future<void> _onCreate(Database db, int version) async {
await db.execute('''
CREATE TABLE tracks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
start_time TEXT NOT NULL,
end_time TEXT,
status TEXT NOT NULL DEFAULT 'recording',
source TEXT NOT NULL DEFAULT 'Telefon GPS',
distance_meters REAL NOT NULL DEFAULT 0,
point_count INTEGER NOT NULL DEFAULT 0
)
''');
await db.execute('''
CREATE TABLE track_points (
id INTEGER PRIMARY KEY AUTOINCREMENT,
track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
latitude REAL NOT NULL,
longitude REAL NOT NULL,
altitude REAL,
accuracy REAL,
speed REAL,
heading REAL,
timestamp TEXT NOT NULL
)
''');
await db.execute(
'CREATE INDEX idx_tp_track ON track_points(track_id, timestamp)');
}
// ─── Tracks CRUD ───────────────────────────────────────────────────────────
Future<int> insertTrack(Track track) async {
final db = await database;
return db.insert('tracks', track.toMap());
}
Future<void> updateTrack(Track track) async {
final db = await database;
await db.update('tracks', track.toMap(),
where: 'id = ?', whereArgs: [track.id]);
}
Future<void> deleteTrack(int id) async {
final db = await database;
await db.delete('tracks', where: 'id = ?', whereArgs: [id]);
}
Future<List<Track>> listTracks() async {
final db = await database;
final rows = await db.query('tracks', orderBy: 'start_time DESC');
return rows.map(Track.fromMap).toList();
}
Future<Track?> getTrack(int id) async {
final db = await database;
final rows =
await db.query('tracks', where: 'id = ?', whereArgs: [id], limit: 1);
return rows.isEmpty ? null : Track.fromMap(rows.first);
}
// ─── TrackPoints ───────────────────────────────────────────────────────────
/// Egyetlen pont hozzáadása + track statisztikák atomi frissítése.
Future<void> addPoint(TrackPoint point, double newDistance) async {
final db = await database;
await db.transaction((txn) async {
await txn.insert('track_points', point.toMap());
await txn.rawUpdate('''
UPDATE tracks
SET distance_meters = ?,
point_count = point_count + 1
WHERE id = ?
''', [newDistance, point.trackId]);
});
}
Future<List<TrackPoint>> getPoints(int trackId) async {
final db = await database;
final rows = await db.query(
'track_points',
where: 'track_id = ?',
whereArgs: [trackId],
orderBy: 'timestamp ASC',
);
return rows.map(TrackPoint.fromMap).toList();
}
/// Csak a koordinátákat adja vissza — a térkép polyline-hoz elég.
Future<List<({double lat, double lon})>> getLatLons(int trackId) async {
final db = await database;
final rows = await db.query(
'track_points',
columns: ['latitude', 'longitude'],
where: 'track_id = ?',
whereArgs: [trackId],
orderBy: 'timestamp ASC',
);
return rows
.map((r) =>
(lat: r['latitude'] as double, lon: r['longitude'] as double))
.toList();
}
}