Track, nyomkövetés hozzáadása
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>')
|
||||
.replaceAll('"', '"');
|
||||
}
|
||||
|
||||
// ignore: non_constant_identifier_names
|
||||
final utf8_encoding = const SystemEncoding();
|
||||
@@ -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, 0–360, É=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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user