From 7192fa53229df12aeb136a53c26f26ef42239b73 Mon Sep 17 00:00:00 2001 From: "torok.istvan" Date: Sun, 10 May 2026 02:31:27 +0200 Subject: [PATCH] =?UTF-8?q?Track,=20nyomk=C3=B6vet=C3=A9s=20hozz=C3=A1ad?= =?UTF-8?q?=C3=A1sa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle | 2 +- android/app/src/main/AndroidManifest.xml | 5 + ios/Runner/Info.plist | 9 + lib/models/track.dart | 160 ++++++++ .../home/presentation/views/home_view.dart | 18 +- .../controllers/map_controller.dart | 45 ++- .../tracking/bindings/tracking_bindings.dart | 4 +- .../controllers/tracking_controller.dart | 312 +++++++++++++- .../presentation/views/track_list_view.dart | 259 ++++++++++++ .../presentation/views/tracking_view.dart | 381 +++++++++++++++++- lib/services/ble_gnss_source.dart | 76 ++++ lib/services/gpx_exporter.dart | 81 ++++ lib/services/location_source.dart | 61 +++ lib/services/phone_gps_source.dart | 93 +++++ lib/services/track_database.dart | 132 ++++++ pubspec.yaml | 2 + 16 files changed, 1613 insertions(+), 27 deletions(-) create mode 100644 lib/models/track.dart create mode 100644 lib/pages/tracking/presentation/views/track_list_view.dart create mode 100644 lib/services/ble_gnss_source.dart create mode 100644 lib/services/gpx_exporter.dart create mode 100644 lib/services/location_source.dart create mode 100644 lib/services/phone_gps_source.dart create mode 100644 lib/services/track_database.dart diff --git a/android/app/build.gradle b/android/app/build.gradle index 124771f..34abfae 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -36,7 +36,7 @@ android { applicationId "hu.app_dev.terepi_seged" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration. - minSdkVersion 23 + minSdkVersion 26 targetSdkVersion 35 versionCode flutter.versionCode versionName flutter.versionName diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 4a96329..0378876 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -5,6 +5,11 @@ android:name="${applicationName}" android:requestLegacyExternalStorage="true" android:icon="@mipmap/ic_launcher"> + + $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSLocationWhenInUseUsageDescription + A nyomvonal rögzítéséhez folyamatos helymeghatározás szükséges. + NSLocationAlwaysAndWhenInUseUsageDescription + Háttérben futó track rögzítéséhez szükséges. + UIBackgroundModes + + location + fetch + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile diff --git a/lib/models/track.dart b/lib/models/track.dart new file mode 100644 index 0000000..2636a98 --- /dev/null +++ b/lib/models/track.dart @@ -0,0 +1,160 @@ +import 'dart:math' as math; + +// ─── TrackPoint ─────────────────────────────────────────────────────────────── + +class TrackPoint { + final int? id; + final int trackId; + final double latitude; + final double longitude; + final double? altitude; + final double? accuracy; + final double? speed; // m/s + final double? heading; + final DateTime timestamp; + + const TrackPoint({ + this.id, + required this.trackId, + required this.latitude, + required this.longitude, + this.altitude, + this.accuracy, + this.speed, + this.heading, + required this.timestamp, + }); + + Map toMap() => { + if (id != null) 'id': id, + 'track_id': trackId, + 'latitude': latitude, + 'longitude': longitude, + 'altitude': altitude, + 'accuracy': accuracy, + 'speed': speed, + 'heading': heading, + 'timestamp': timestamp.toIso8601String(), + }; + + factory TrackPoint.fromMap(Map m) => TrackPoint( + id: m['id'] as int?, + trackId: m['track_id'] as int, + latitude: m['latitude'] as double, + longitude: m['longitude'] as double, + altitude: m['altitude'] as double?, + accuracy: m['accuracy'] as double?, + speed: m['speed'] as double?, + heading: m['heading'] as double?, + timestamp: DateTime.parse(m['timestamp'] as String), + ); +} + +// ─── Track státusz ──────────────────────────────────────────────────────────── + +enum TrackStatus { recording, paused, finished } + +// ─── Track ─────────────────────────────────────────────────────────────────── + +class Track { + final int? id; + final String name; + final DateTime startTime; + final DateTime? endTime; + final TrackStatus status; + final String source; // pl. "Telefon GPS", "BLE GNSS" + + // Statisztikák — ezeket a DB is tárolja a gyors listázáshoz + final double distanceMeters; + final int pointCount; + + const Track({ + this.id, + required this.name, + required this.startTime, + this.endTime, + this.status = TrackStatus.recording, + this.source = 'Telefon GPS', + this.distanceMeters = 0, + this.pointCount = 0, + }); + + Track copyWith({ + int? id, + String? name, + DateTime? startTime, + DateTime? endTime, + TrackStatus? status, + String? source, + double? distanceMeters, + int? pointCount, + }) => + Track( + id: id ?? this.id, + name: name ?? this.name, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + status: status ?? this.status, + source: source ?? this.source, + distanceMeters: distanceMeters ?? this.distanceMeters, + pointCount: pointCount ?? this.pointCount, + ); + + /// Formázott időtartam (óó:pp:mm) + String get durationFormatted { + final end = endTime ?? DateTime.now(); + final d = end.difference(startTime); + final h = d.inHours.toString().padLeft(2, '0'); + final m = (d.inMinutes % 60).toString().padLeft(2, '0'); + final s = (d.inSeconds % 60).toString().padLeft(2, '0'); + return '$h:$m:$s'; + } + + /// Formázott távolság + String get distanceFormatted { + if (distanceMeters < 1000) { + return '${distanceMeters.toStringAsFixed(0)} m'; + } + return '${(distanceMeters / 1000).toStringAsFixed(2)} km'; + } + + Map toMap() => { + if (id != null) 'id': id, + 'name': name, + 'start_time': startTime.toIso8601String(), + 'end_time': endTime?.toIso8601String(), + 'status': status.name, + 'source': source, + 'distance_meters': distanceMeters, + 'point_count': pointCount, + }; + + factory Track.fromMap(Map m) => Track( + id: m['id'] as int?, + name: m['name'] as String, + startTime: DateTime.parse(m['start_time'] as String), + endTime: m['end_time'] != null + ? DateTime.parse(m['end_time'] as String) + : null, + status: TrackStatus.values.byName(m['status'] as String), + source: m['source'] as String? ?? 'Telefon GPS', + distanceMeters: (m['distance_meters'] as num?)?.toDouble() ?? 0, + pointCount: m['point_count'] as int? ?? 0, + ); +} + +// ─── Segédszámítás: Haversine távolság ─────────────────────────────────────── + +double haversineMeters(double lat1, double lon1, double lat2, double lon2) { + const r = 6371000.0; + final dLat = _toRad(lat2 - lat1); + final dLon = _toRad(lon2 - lon1); + final a = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(_toRad(lat1)) * + math.cos(_toRad(lat2)) * + math.sin(dLon / 2) * + math.sin(dLon / 2); + return r * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); +} + +double _toRad(double deg) => deg * math.pi / 180; diff --git a/lib/pages/home/presentation/views/home_view.dart b/lib/pages/home/presentation/views/home_view.dart index 7f8cbb6..a3ebd13 100644 --- a/lib/pages/home/presentation/views/home_view.dart +++ b/lib/pages/home/presentation/views/home_view.dart @@ -175,15 +175,15 @@ class HomeView extends GetView { iconData: Icons.edit_road, label: "Track", onPressed: () async { - // Get.toNamed("/navigation"); - ScaffoldMessenger.of(context) - .showSnackBar(const SnackBar( - content: Text( - "Fejlesztlés alatt", - style: TextStyle(fontWeight: FontWeight.bold), - ), - backgroundColor: Colors.black54, - )); + Get.toNamed("/tracking"); + // ScaffoldMessenger.of(context) + // .showSnackBar(const SnackBar( + // content: Text( + // "Fejlesztlés alatt", + // style: TextStyle(fontWeight: FontWeight.bold), + // ), + // backgroundColor: Colors.black54, + // )); }, ), ], diff --git a/lib/pages/map/presentation/controllers/map_controller.dart b/lib/pages/map/presentation/controllers/map_controller.dart index 8885a68..50d1b0e 100644 --- a/lib/pages/map/presentation/controllers/map_controller.dart +++ b/lib/pages/map/presentation/controllers/map_controller.dart @@ -104,6 +104,7 @@ class MapViewController extends GetxController { late GeoidGrid geoidGrid; StreamSubscription? _phoneLocationSub; + final _phoneLocation = Location(); TextEditingController pointIdController = TextEditingController(); TextEditingController pointDescriptionController = TextEditingController(); @@ -325,30 +326,48 @@ class MapViewController extends GetxController { _updateCurrentLocationMarker(); if (!gpsIsConnected.value) { - _startPhoneGps(); + await _startPhoneGps(); } } - void _startPhoneGps() async { - // Ha már fut, nem indítjuk újra + Future _startPhoneGps() async { if (_phoneLocationSub != null) return; - final location = Location(); + // Frissítési beállítások — ezt a location csomag megköveteli + await _phoneLocation.changeSettings( + accuracy: LocationAccuracy.high, + interval: 1000, // ms — másodpercenkénti frissítés + distanceFilter: 0, // méter — minden frissítés jöjjön + ); - // Engedélyek — már megvan a _getInitialLocation()-ból, - // de biztonságos újra ellenőrizni - final permission = await location.hasPermission(); - if (permission == PermissionStatus.denied) return; + // Engedély teljes körű ellenőrzése + var permission = await _phoneLocation.hasPermission(); + if (permission == PermissionStatus.denied) { + permission = await _phoneLocation.requestPermission(); + } + if (permission != PermissionStatus.granted && + permission != PermissionStatus.grantedLimited) { + return; + } - // Folyamatos frissítés indítása - _phoneLocationSub = location.onLocationChanged.listen((LocationData data) { + // GPS szolgáltatás ellenőrzése + bool serviceEnabled = await _phoneLocation.serviceEnabled(); + if (!serviceEnabled) { + serviceEnabled = await _phoneLocation.requestService(); + if (!serviceEnabled) return; + } + + print('Phone GPS: stream starting...'); + + _phoneLocationSub = _phoneLocation.onLocationChanged.listen((data) { if (gpsIsConnected.value) { - // Ha közben csatlakozott a külső GPS — leállítjuk magunkat _stopPhoneGps(); return; } - currentLatitude.value = data.latitude ?? currentLatitude.value; - currentLongitude.value = data.longitude ?? currentLongitude.value; + if (data.latitude == null || data.longitude == null) return; + + currentLatitude.value = data.latitude!; + currentLongitude.value = data.longitude!; _updateCurrentLocationMarker(); }); } diff --git a/lib/pages/tracking/bindings/tracking_bindings.dart b/lib/pages/tracking/bindings/tracking_bindings.dart index 7c544ca..5ec4a90 100644 --- a/lib/pages/tracking/bindings/tracking_bindings.dart +++ b/lib/pages/tracking/bindings/tracking_bindings.dart @@ -4,6 +4,8 @@ import 'package:terepi_seged/pages/tracking/presentation/controllers/tracking_co class TrackingBinding extends Bindings { @override void dependencies() { - Get.lazyPut(() => TrackingController()); + if (!Get.isRegistered()) { + Get.put(TrackingController(), permanent: true); + } } } diff --git a/lib/pages/tracking/presentation/controllers/tracking_controller.dart b/lib/pages/tracking/presentation/controllers/tracking_controller.dart index 75a2c7d..7eabedf 100644 --- a/lib/pages/tracking/presentation/controllers/tracking_controller.dart +++ b/lib/pages/tracking/presentation/controllers/tracking_controller.dart @@ -1,3 +1,313 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:get/get.dart'; +import 'package:intl/intl.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:share_plus/share_plus.dart'; -class TrackingController extends GetxController {} +import '../../../../services/location_source.dart'; +import '../../../../services/phone_gps_source.dart'; +import '../../../../services/track_database.dart'; +import '../../../../services/gpx_exporter.dart'; +import '../../../../models/track.dart'; + +// ─── Foreground task handler ───────────────────────────────────────────────── +// Ez a függvény a háttér-isolate-ban fut. Csak a meglevő pozíció-streamet +// tartja fenn, a tényleges adatfeldolgozás a főszálban történik. + +@pragma('vm:entry-point') +void startTrackingCallback() { + FlutterForegroundTask.setTaskHandler(_TrackingTaskHandler()); +} + +class _TrackingTaskHandler extends TaskHandler { + @override + Future onStart(DateTime timestamp, TaskStarter starter) async {} + + @override + void onRepeatEvent(DateTime timestamp) { + // A Geolocator stream a főizoláton folyik tovább, itt nincs teendő. + } + + @override + Future onDestroy(DateTime timestamp, bool isTimeout) async {} +} + +// ─── TrackingController ────────────────────────────────────────────────────── + +class TrackingController extends GetxController { + // ── Állapot ──────────────────────────────────────────────────────────────── + final isRecording = false.obs; + final isPaused = false.obs; + final currentTrack = Rxn(); + + /// Aktuális session pontjai a térképhez (csak a memóriában). + final livePoints = [].obs; + + /// Aktuális sebesség [km/h]. + final currentSpeedKmh = 0.0.obs; + + /// Megtett távolság [m] az aktuális sessionben. + final sessionDistance = 0.0.obs; + + /// Eltelt idő formátuma óó:pp:mm. + final elapsedFormatted = '00:00:00'.obs; + + /// Előtér/háttér állapot jelzése. + final isInBackground = false.obs; + + // ── Mentett track-ek listája ──────────────────────────────────────────────── + final savedTracks = [].obs; + + // ── Belső állapot ────────────────────────────────────────────────────────── + LocationSource? _source; + StreamSubscription? _positionSub; + Timer? _elapsedTimer; + + TrackPoint? _lastPoint; + DateTime? _sessionStart; + double _accumulatedDistance = 0; + + final _db = TrackDatabase.instance; + final _exporter = GpxExporter(); + + // ── Inicializálás ────────────────────────────────────────────────────────── + + @override + void onInit() { + super.onInit(); + _initForegroundTask(); + loadSavedTracks(); + } + + void _initForegroundTask() { + FlutterForegroundTask.init( + androidNotificationOptions: AndroidNotificationOptions( + channelId: 'tracking_channel', + channelName: 'Nyomvonal rögzítés', + channelDescription: 'Aktív track rögzítése folyamatban', + channelImportance: NotificationChannelImportance.LOW, + priority: NotificationPriority.LOW, + ), + iosNotificationOptions: const IOSNotificationOptions( + showNotification: true, + playSound: false, + ), + foregroundTaskOptions: ForegroundTaskOptions( + eventAction: ForegroundTaskEventAction.repeat(1000), + autoRunOnBoot: false, + allowWakeLock: true, + allowWifiLock: false, + ), + ); + } + + // ── Publikus API ─────────────────────────────────────────────────────────── + + /// Elindítja a rögzítést a megadott forrással (alapértelmezett: telefon GPS). + Future startRecording({LocationSource? source}) async { + if (isRecording.value) return; + + _source = source ?? PhoneGpsSource(intervalMs: 1000, distanceFilter: 2.0); + + // Track létrehozása az adatbázisban + final now = DateTime.now(); + final name = DateFormat('yyyy-MM-dd HH:mm').format(now); + final trackId = await _db.insertTrack(Track( + name: name, + startTime: now, + source: _source!.displayName, + )); + currentTrack.value = await _db.getTrack(trackId); + + // Állapot reset + livePoints.clear(); + sessionDistance.value = 0; + _accumulatedDistance = 0; + _lastPoint = null; + _sessionStart = now; + + // Foreground service indítása (háttér-működéshez) + await FlutterForegroundTask.startService( + notificationTitle: 'Track rögzítése', + notificationText: name, + callback: startTrackingCallback, + ); + + // GPS stream feliratkozás + _positionSub = _source!.positionStream.listen( + _onPosition, + onError: (e) { + Get.snackbar('GPS hiba', e.toString(), + backgroundColor: Colors.red, colorText: Colors.white); + stopRecording(); + }, + ); + + // Időmérő + _startElapsedTimer(); + + isRecording.value = true; + isPaused.value = false; + } + + void pauseRecording() { + if (!isRecording.value || isPaused.value) return; + _positionSub?.pause(); + _elapsedTimer?.cancel(); + isPaused.value = true; + FlutterForegroundTask.updateService( + notificationTitle: 'Track szüneteltetve', + notificationText: currentTrack.value?.name ?? '', + ); + } + + void resumeRecording() { + if (!isPaused.value) return; + _positionSub?.resume(); + _startElapsedTimer(); + isPaused.value = false; + FlutterForegroundTask.updateService( + notificationTitle: 'Track rögzítése', + notificationText: currentTrack.value?.name ?? '', + ); + } + + Future stopRecording() async { + if (!isRecording.value) return; + + await _positionSub?.cancel(); + _positionSub = null; + _elapsedTimer?.cancel(); + _elapsedTimer = null; + + // Track lezárása az adatbázisban + final track = currentTrack.value; + if (track?.id != null) { + final finished = track!.copyWith( + status: TrackStatus.finished, + endTime: DateTime.now(), + ); + await _db.updateTrack(finished); + currentTrack.value = finished; + } + + await FlutterForegroundTask.stopService(); + await _source?.dispose(); + _source = null; + + isRecording.value = false; + isPaused.value = false; + + await loadSavedTracks(); + } + + Future loadSavedTracks() async { + savedTracks.value = await _db.listTracks(); + } + + Future deleteTrack(int id) async { + await _db.deleteTrack(id); + await loadSavedTracks(); + } + + /// GPX export + rendszer megosztás dialóg. + Future exportTrack(Track track) async { + try { + final path = await _exporter.export(track); + await Share.shareXFiles([XFile(path)], + subject: 'Nyomvonal: ${track.name}'); + } catch (e) { + Get.snackbar('Export hiba', e.toString(), + backgroundColor: Colors.red, colorText: Colors.white); + } + } + + /// Visszatölt egy mentett track pontjait a térképre (megtekintés). + Future> loadTrackPoints(int trackId) async { + final pts = await _db.getLatLons(trackId); + return pts.map((p) => LatLng(p.lat, p.lon)).toList(); + } + + // ── Belső logika ─────────────────────────────────────────────────────────── + + Future _onPosition(SourcePosition pos) async { + if (isPaused.value) return; + + final trackId = currentTrack.value?.id; + if (trackId == null) return; + + // Távolság a legutóbbi ponttól + double segmentDist = 0; + if (_lastPoint != null) { + segmentDist = haversineMeters( + _lastPoint!.latitude, + _lastPoint!.longitude, + pos.latitude, + pos.longitude, + ); + // Szűrés: ugrásszerű változás (pl. GPS lock elvesztése) ignorálása + if (segmentDist > 100) return; + } + + _accumulatedDistance += segmentDist; + sessionDistance.value = _accumulatedDistance; + + // Sebesség km/h-ban + currentSpeedKmh.value = (pos.speed ?? 0) * 3.6; + + // Pont mentése + final point = TrackPoint( + trackId: trackId, + latitude: pos.latitude, + longitude: pos.longitude, + altitude: pos.altitude, + accuracy: pos.accuracy, + speed: pos.speed, + heading: pos.heading, + timestamp: pos.timestamp, + ); + await _db.addPoint(point, _accumulatedDistance); + + _lastPoint = point; + + // UI frissítés + livePoints.add(LatLng(pos.latitude, pos.longitude)); + + // Értesítés frissítése + if (livePoints.length % 10 == 0) { + final dist = _formatDistance(_accumulatedDistance); + FlutterForegroundTask.updateService( + notificationTitle: 'Track rögzítése – $dist', + notificationText: elapsedFormatted.value, + ); + } + } + + void _startElapsedTimer() { + _elapsedTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (_sessionStart == null) return; + final d = DateTime.now().difference(_sessionStart!); + final h = d.inHours.toString().padLeft(2, '0'); + final m = (d.inMinutes % 60).toString().padLeft(2, '0'); + final s = (d.inSeconds % 60).toString().padLeft(2, '0'); + elapsedFormatted.value = '$h:$m:$s'; + }); + } + + String _formatDistance(double m) { + if (m < 1000) return '${m.toStringAsFixed(0)} m'; + return '${(m / 1000).toStringAsFixed(2)} km'; + } + + @override + void onClose() { + stopRecording(); + if (!isRecording.value) { + _positionSub?.cancel(); + _elapsedTimer?.cancel(); + } + super.onClose(); + } +} diff --git a/lib/pages/tracking/presentation/views/track_list_view.dart b/lib/pages/tracking/presentation/views/track_list_view.dart new file mode 100644 index 0000000..b038bca --- /dev/null +++ b/lib/pages/tracking/presentation/views/track_list_view.dart @@ -0,0 +1,259 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:get/get.dart'; +import 'package:latlong2/latlong.dart'; + +import '../controllers/tracking_controller.dart'; +import '../../../../models/track.dart'; + +/// Mentett track-ek listája megtekintési és export lehetőséggel. +class TrackListView extends GetView { + const TrackListView({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Mentett track-ek'), + actions: [ + IconButton( + icon: const Icon(Icons.refresh), + onPressed: controller.loadSavedTracks, + ) + ], + ), + body: Obx(() { + final tracks = controller.savedTracks; + if (tracks.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.route, size: 64, color: Colors.grey.shade300), + const SizedBox(height: 12), + Text('Még nincs mentett nyomvonal', + style: TextStyle(color: Colors.grey.shade500)), + ], + ), + ); + } + + return ListView.separated( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: tracks.length, + separatorBuilder: (_, __) => const Divider(height: 1), + itemBuilder: (context, i) => _TrackTile(track: tracks[i]), + ); + }), + ); + } +} + +class _TrackTile extends GetView { + final Track track; + const _TrackTile({required this.track}); + + @override + Widget build(BuildContext context) { + final statusColor = switch (track.status) { + TrackStatus.recording => Colors.red, + TrackStatus.paused => Colors.orange, + TrackStatus.finished => Colors.green, + }; + + return Dismissible( + key: ValueKey(track.id), + direction: DismissDirection.endToStart, + confirmDismiss: (_) => _confirm(context), + onDismissed: (_) => controller.deleteTrack(track.id!), + background: Container( + color: Colors.red, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: const Icon(Icons.delete, color: Colors.white), + ), + child: ListTile( + leading: CircleAvatar( + backgroundColor: statusColor.withOpacity(0.15), + child: Icon(Icons.route, color: statusColor), + ), + title: Text(track.name, + style: const TextStyle(fontWeight: FontWeight.w600)), + subtitle: Text( + '${track.distanceFormatted} · ${track.durationFormatted}' + ' · ${track.pointCount} pont\n${track.source}', + ), + isThreeLine: true, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.map_outlined), + tooltip: 'Megtekintés', + onPressed: () => Get.to(() => _TrackPreviewView(track: track)), + ), + IconButton( + icon: const Icon(Icons.share), + tooltip: 'GPX export', + onPressed: () => controller.exportTrack(track), + ), + ], + ), + ), + ); + } + + Future _confirm(BuildContext context) async { + return await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Track törlése'), + content: Text('"${track.name}" törlése visszavonohatalan.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Mégse')), + FilledButton.tonal( + style: FilledButton.styleFrom( + backgroundColor: Colors.red.shade50), + onPressed: () => Navigator.pop(context, true), + child: const Text('Törlés', + style: TextStyle(color: Colors.red))), + ], + ), + ) ?? + false; + } +} + +// ─── Track előnézeti térkép ────────────────────────────────────────────────── + +class _TrackPreviewView extends GetView { + final Track track; + const _TrackPreviewView({required this.track}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(track.name), + actions: [ + IconButton( + icon: const Icon(Icons.share), + onPressed: () => controller.exportTrack(track), + ) + ], + ), + body: FutureBuilder>( + future: controller.loadTrackPoints(track.id!), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Center(child: CircularProgressIndicator()); + } + if (snapshot.hasError) { + return Center(child: Text('Hiba: ${snapshot.error}')); + } + final points = snapshot.data ?? []; + if (points.isEmpty) { + return const Center(child: Text('Nincs megjeleníthető pont.')); + } + + // Bounding box számítás automatikus zoom-hoz + final lats = points.map((p) => p.latitude).toList(); + final lons = points.map((p) => p.longitude).toList(); + final center = LatLng( + (lats.reduce((a, b) => a + b)) / lats.length, + (lons.reduce((a, b) => a + b)) / lons.length, + ); + + return Column( + children: [ + Expanded( + child: FlutterMap( + options: MapOptions( + initialCenter: center, + initialZoom: 14, + initialCameraFit: CameraFit.coordinates( + coordinates: points, + padding: const EdgeInsets.all(40), + ), + ), + children: [ + TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'hu.app_dev.terepi_seged', + ), + PolylineLayer( + polylines: [ + Polyline( + points: points, + color: Colors.blue.shade700, + strokeWidth: 4.0, + borderColor: Colors.blue.shade200, + borderStrokeWidth: 1.5, + ), + ], + ), + MarkerLayer( + markers: [ + Marker( + point: points.first, + child: const Icon(Icons.flag, + color: Colors.green, size: 28), + ), + Marker( + point: points.last, + child: const Icon(Icons.flag, + color: Colors.red, size: 28), + ), + ], + ), + ], + ), + ), + // Statisztika sáv alul + Container( + color: Theme.of(context).colorScheme.surface, + padding: EdgeInsets.fromLTRB( + 24, 12, 24, 12 + MediaQuery.of(context).padding.bottom), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _InfoChip( + icon: Icons.straighten, text: track.distanceFormatted), + _InfoChip( + icon: Icons.timer_outlined, + text: track.durationFormatted), + _InfoChip( + icon: Icons.location_on_outlined, + text: '${track.pointCount} pont'), + _InfoChip(icon: Icons.sensors, text: track.source), + ], + ), + ), + ], + ); + }, + ), + ); + } +} + +class _InfoChip extends StatelessWidget { + final IconData icon; + final String text; + const _InfoChip({required this.icon, required this.text}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 15, color: Theme.of(context).colorScheme.primary), + const SizedBox(width: 4), + Text(text, style: const TextStyle(fontSize: 13)), + ], + ); + } +} diff --git a/lib/pages/tracking/presentation/views/tracking_view.dart b/lib/pages/tracking/presentation/views/tracking_view.dart index cc16d94..7499766 100644 --- a/lib/pages/tracking/presentation/views/tracking_view.dart +++ b/lib/pages/tracking/presentation/views/tracking_view.dart @@ -1,13 +1,390 @@ -import 'package:get/get.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:get/get.dart'; +import 'package:latlong2/latlong.dart'; import '../controllers/tracking_controller.dart'; +import '../../../../models/track.dart'; +import 'track_list_view.dart'; class TrackingView extends GetView { const TrackingView({Key? key}) : super(key: key); @override Widget build(BuildContext context) { - return Container(); + return Scaffold( + extendBody: true, + appBar: AppBar( + title: const Text('Nyomvonal'), + actions: [ + IconButton( + icon: const Icon(Icons.list_alt), + tooltip: 'Mentett track-ek', + onPressed: () => Get.to(() => const TrackListView()), + ), + ], + ), + body: Stack( + children: [ + _LiveMap(), + _StatsPanel(), + ], + ), + bottomNavigationBar: _ControlBar(), + ); + } +} + +// ─── Térkép ───────────────────────────────────────────────────────────────── + +class _LiveMap extends GetView { + const _LiveMap(); + + @override + Widget build(BuildContext context) { + final mapController = MapController(); + + return Obx(() { + final points = controller.livePoints; + final center = points.isNotEmpty + ? points.last + : const LatLng(47.5, 19.0); // Magyarország közepe + + // Középre követ rögzítés közben + if (controller.isRecording.value && points.isNotEmpty) { + WidgetsBinding.instance.addPostFrameCallback((_) { + mapController.move(points.last, mapController.camera.zoom); + }); + } + + return FlutterMap( + mapController: mapController, + options: MapOptions( + initialCenter: center, + initialZoom: 15.0, + interactionOptions: const InteractionOptions( + flags: InteractiveFlag.all, + ), + ), + children: [ + TileLayer( + urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'hu.app_dev.terepi_seged', + ), + if (points.length >= 2) + PolylineLayer( + polylines: [ + Polyline( + points: points, + color: Colors.blue.shade700, + strokeWidth: 4.0, + borderColor: Colors.blue.shade200, + borderStrokeWidth: 1.5, + ), + ], + ), + if (points.isNotEmpty) + MarkerLayer( + markers: [ + // Kezdőpont + Marker( + point: points.first, + child: const Icon(Icons.flag, color: Colors.green, size: 28), + ), + // Aktuális pozíció + Marker( + point: points.last, + child: _PulsingDot( + color: + controller.isPaused.value ? Colors.orange : Colors.blue, + ), + ), + ], + ), + ], + ); + }); + } +} + +/// Pulzáló pont az aktuális pozícióhoz. +class _PulsingDot extends StatefulWidget { + final Color color; + const _PulsingDot({required this.color}); + + @override + State<_PulsingDot> createState() => _PulsingDotState(); +} + +class _PulsingDotState extends State<_PulsingDot> + with SingleTickerProviderStateMixin { + late AnimationController _anim; + late Animation _scale; + + @override + void initState() { + super.initState(); + _anim = AnimationController( + vsync: this, duration: const Duration(milliseconds: 1000)) + ..repeat(reverse: true); + _scale = Tween(begin: 0.8, end: 1.3) + .animate(CurvedAnimation(parent: _anim, curve: Curves.easeInOut)); + } + + @override + void dispose() { + _anim.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ScaleTransition( + scale: _scale, + child: Container( + width: 18, + height: 18, + decoration: BoxDecoration( + color: widget.color, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + boxShadow: [ + BoxShadow( + color: widget.color.withOpacity(0.5), + blurRadius: 8, + spreadRadius: 2) + ], + ), + ), + ); + } +} + +// ─── Statisztika panel ──────────────────────────────────────────────────────── + +class _StatsPanel extends GetView { + const _StatsPanel(); + + @override + Widget build(BuildContext context) { + return Obx(() { + if (!controller.isRecording.value && controller.livePoints.isEmpty) { + return const SizedBox.shrink(); + } + + return Positioned( + top: 12, + left: 12, + right: 12, + child: Card( + elevation: 4, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + color: Theme.of(context).colorScheme.surface.withOpacity(0.92), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _Stat( + icon: Icons.timer_outlined, + label: 'Idő', + value: controller.elapsedFormatted.value, + ), + _Divider(), + _Stat( + icon: Icons.straighten, + label: 'Távolság', + value: _formatDist(controller.sessionDistance.value), + ), + _Divider(), + _Stat( + icon: Icons.speed, + label: 'Sebesség', + value: + '${controller.currentSpeedKmh.value.toStringAsFixed(1)} km/h', + ), + _Divider(), + _Stat( + icon: Icons.location_on_outlined, + label: 'Pontok', + value: controller.livePoints.length.toString(), + ), + ], + ), + ), + ), + ); + }); + } + + String _formatDist(double m) { + if (m < 1000) return '${m.toStringAsFixed(0)} m'; + return '${(m / 1000).toStringAsFixed(2)} km'; + } +} + +class _Stat extends StatelessWidget { + final IconData icon; + final String label; + final String value; + const _Stat({required this.icon, required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: Theme.of(context).colorScheme.primary), + const SizedBox(height: 2), + Text(value, + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 15)), + Text(label, + style: TextStyle(fontSize: 10, color: Colors.grey.shade600)), + ], + ); + } +} + +class _Divider extends StatelessWidget { + const _Divider(); + @override + Widget build(BuildContext context) => + Container(height: 36, width: 1, color: Colors.grey.shade300); +} + +// ─── Vezérlő sáv ──────────────────────────────────────────────────────────── + +class _ControlBar extends GetView { + const _ControlBar(); + + @override + Widget build(BuildContext context) { + return Obx(() { + final recording = controller.isRecording.value; + final paused = controller.isPaused.value; + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.12), + blurRadius: 8, + offset: const Offset(0, -2)) + ], + ), + padding: EdgeInsets.fromLTRB( + 24, 12, 24, 12 + MediaQuery.of(context).padding.bottom), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + if (!recording) ...[ + // ── Nincs aktív track ── + _RoundButton( + icon: Icons.fiber_manual_record, + label: 'Indítás', + color: Colors.red, + onTap: () => controller.startRecording(), + ), + ] else ...[ + // ── Aktív track ── + _RoundButton( + icon: paused ? Icons.play_arrow : Icons.pause, + label: paused ? 'Folytatás' : 'Szünet', + color: Colors.orange, + onTap: paused + ? controller.resumeRecording + : controller.pauseRecording, + ), + // Nagy Stop gomb középen + GestureDetector( + onTap: () => _confirmStop(context), + child: Container( + width: 68, + height: 68, + decoration: BoxDecoration( + color: Colors.red, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.red.withOpacity(0.4), + blurRadius: 12, + spreadRadius: 2) + ], + ), + child: const Icon(Icons.stop, color: Colors.white, size: 32), + ), + ), + _RoundButton( + icon: Icons.share, + label: 'Export', + color: Colors.blue, + onTap: () { + final t = controller.currentTrack.value; + if (t != null) controller.exportTrack(t); + }, + ), + ], + ], + ), + ); + }); + } + + Future _confirmStop(BuildContext context) async { + final ok = await showDialog( + context: context, + builder: (_) => AlertDialog( + title: const Text('Track leállítása'), + content: const Text( + 'Leállítja a rögzítést? A track automatikusan mentésre kerül.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('Mégse')), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('Leállítás')), + ], + ), + ); + if (ok == true) controller.stopRecording(); + } +} + +class _RoundButton extends StatelessWidget { + final IconData icon; + final String label; + final Color color; + final VoidCallback onTap; + const _RoundButton( + {required this.icon, + required this.label, + required this.color, + required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 52, + height: 52, + decoration: BoxDecoration( + color: color.withOpacity(0.12), + shape: BoxShape.circle, + border: Border.all(color: color, width: 1.5), + ), + child: Icon(icon, color: color, size: 26), + ), + const SizedBox(height: 4), + Text(label, style: TextStyle(fontSize: 11, color: color)), + ], + ), + ); } } diff --git a/lib/services/ble_gnss_source.dart b/lib/services/ble_gnss_source.dart new file mode 100644 index 0000000..cf29ddc --- /dev/null +++ b/lib/services/ble_gnss_source.dart @@ -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 nmeaStream — a BLE controller adja + final Stream? nmeaStream; + + StreamController? _controller; + StreamSubscription? _sub; + + BleGnssSource({this.nmeaStream}); + + @override + bool get isAvailable => _sub != null; + + @override + Stream get positionStream { + _controller = StreamController.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 dispose() async { + await _sub?.cancel(); + await _controller?.close(); + } +} diff --git a/lib/services/gpx_exporter.dart b/lib/services/gpx_exporter.dart new file mode 100644 index 0000000..6295a2f --- /dev/null +++ b/lib/services/gpx_exporter.dart @@ -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 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 points) { + final buf = StringBuffer(); + buf.writeln(''); + buf.writeln(''); + + buf.writeln(' '); + buf.writeln(' ${_esc(track.name)}'); + buf.writeln( + ' '); + buf.writeln(' '); + + buf.writeln(' '); + buf.writeln(' ${_esc(track.name)}'); + buf.writeln(' Forrás: ${_esc(track.source)}, ' + '${track.pointCount} pont, ' + '${track.distanceFormatted}'); + buf.writeln(' '); + + for (final pt in points) { + buf.write(' '); + if (pt.altitude != null) { + buf.write('${pt.altitude!.toStringAsFixed(3)}'); + } + buf.write(''); + if (pt.speed != null) { + buf.write('${pt.speed!.toStringAsFixed(2)}'); + } + if (pt.heading != null) { + buf.write('${pt.heading!.toStringAsFixed(1)}'); + } + if (pt.accuracy != null) { + buf.write('${pt.accuracy!.toStringAsFixed(2)}'); + } + buf.writeln(''); + } + + buf.writeln(' '); + buf.writeln(' '); + buf.writeln(''); + return buf.toString(); + } + + String _esc(String s) => s + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"'); +} + +// ignore: non_constant_identifier_names +final utf8_encoding = const SystemEncoding(); diff --git a/lib/services/location_source.dart b/lib/services/location_source.dart new file mode 100644 index 0000000..6698e13 --- /dev/null +++ b/lib/services/location_source.dart @@ -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 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 dispose(); +} diff --git a/lib/services/phone_gps_source.dart b/lib/services/phone_gps_source.dart new file mode 100644 index 0000000..2126fa9 --- /dev/null +++ b/lib/services/phone_gps_source.dart @@ -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? _controller; + StreamSubscription? _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 get positionStream { + _controller = StreamController.broadcast(); + + _startListening(); + return _controller!.stream; + } + + Future _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 dispose() async { + await _positionSub?.cancel(); + await _controller?.close(); + _controller = null; + } +} diff --git a/lib/services/track_database.dart b/lib/services/track_database.dart new file mode 100644 index 0000000..6849f3a --- /dev/null +++ b/lib/services/track_database.dart @@ -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 get database async { + _db ??= await _open(); + return _db!; + } + + Future _open() async { + final dbPath = p.join(await getDatabasesPath(), 'tracks.db'); + return openDatabase( + dbPath, + version: 1, + onCreate: _onCreate, + ); + } + + Future _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 insertTrack(Track track) async { + final db = await database; + return db.insert('tracks', track.toMap()); + } + + Future updateTrack(Track track) async { + final db = await database; + await db.update('tracks', track.toMap(), + where: 'id = ?', whereArgs: [track.id]); + } + + Future deleteTrack(int id) async { + final db = await database; + await db.delete('tracks', where: 'id = ?', whereArgs: [id]); + } + + Future> listTracks() async { + final db = await database; + final rows = await db.query('tracks', orderBy: 'start_time DESC'); + return rows.map(Track.fromMap).toList(); + } + + Future 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 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> 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> 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(); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index daaa5a1..71b9001 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -68,6 +68,8 @@ dependencies: supabase_flutter: ^2.10.2 appwrite: ^20.0.0 share_plus: ^12.0.1 + geolocator: ^14.0.2 + flutter_foreground_task: ^9.2.2 flutter: sdk: flutter