From aa78c7bb6f36bb8fd27501a1f624d11e22bdf786 Mon Sep 17 00:00:00 2001 From: "torok.istvan" Date: Tue, 23 Jun 2026 15:21:20 +0200 Subject: [PATCH] Online tracking, deviceidentityservice --- .../hu/app_dev/terepi_seged/MainActivity.kt | 16 +- lib/main.dart | 4 + lib/models/device_info_model.dart | 68 +++++ lib/models/note_item.dart | 14 +- .../controllers/map_survey_controller.dart | 70 +++++ .../presentations/views/map_survey_view.dart | 144 +++++++++- .../controllers/tracking_controller.dart | 20 +- lib/services/device_identity_service.dart | 195 +++++++++++++ lib/services/track_sync_service.dart | 257 ++++++++++++++++++ lib/widgets/appbar/shell_map_appbar.dart | 42 +-- lib/widgets/map/team_member_widget.dart | 49 ++++ lib/widgets/shared_map_widgets.dart | 2 +- pubspec.yaml | 2 + 13 files changed, 850 insertions(+), 33 deletions(-) create mode 100644 lib/models/device_info_model.dart create mode 100644 lib/services/device_identity_service.dart create mode 100644 lib/services/track_sync_service.dart create mode 100644 lib/widgets/map/team_member_widget.dart diff --git a/android/app/src/main/kotlin/hu/app_dev/terepi_seged/MainActivity.kt b/android/app/src/main/kotlin/hu/app_dev/terepi_seged/MainActivity.kt index 823c4a0..704a64b 100644 --- a/android/app/src/main/kotlin/hu/app_dev/terepi_seged/MainActivity.kt +++ b/android/app/src/main/kotlin/hu/app_dev/terepi_seged/MainActivity.kt @@ -7,7 +7,7 @@ import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.MethodChannel class MainActivity: FlutterActivity() { - private val deviceInfoChannelName = "hu.app_dev.terep_seged/deviceInfo" + private val deviceInfoChannelName = "hu.app_dev.terepi_seged/deviceInfo" override fun configureFlutterEngine(flutterEngine: FlutterEngine){ super.configureFlutterEngine(flutterEngine) @@ -20,6 +20,9 @@ class MainActivity: FlutterActivity() { "getAndroidDeviceName"->{ result.success(getAndroidDeviceName()) } + "getAndroidId"->{ + result.success(getAndroidId()) + } else -> result.notImplemented() } } @@ -42,4 +45,15 @@ class MainActivity: FlutterActivity() { return "$manufacturer $model".trim() } + private fun getAndroidId(): String? { + return try { + Settings.Secure.getString( + contentResolver, + Settings.Secure.ANDROID_ID + ) + } catch (e: Exception){ + null + } + } + } diff --git a/lib/main.dart b/lib/main.dart index 056dc49..9627183 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -7,6 +7,7 @@ import 'package:terepi_seged/pages/tracking/presentation/controllers/tracking_co import 'package:terepi_seged/routes/app_pages.dart'; import 'package:terepi_seged/services/app_database.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_device_service.dart'; import 'package:terepi_seged/services/gnss/gnss_service.dart'; import 'package:terepi_seged/services/layer_import_service.dart'; @@ -14,6 +15,7 @@ import 'package:terepi_seged/services/note_audio_service.dart'; import 'package:terepi_seged/services/note_photo_service.dart'; import 'package:terepi_seged/services/ntrip_service.dart'; import 'package:terepi_seged/services/project_service.dart'; +import 'package:terepi_seged/services/track_sync_service.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -36,6 +38,8 @@ Future main() async { Get.put(NotePhotoService(), permanent: true); Get.put(NoteAudioService(), permanent: true); Get.put(LayerImportService(), permanent: true); + Get.put(DeviceIdentityService(), permanent: true); + Get.put(TrackSyncService(), permanent: true); runApp(const MyApp()); } diff --git a/lib/models/device_info_model.dart b/lib/models/device_info_model.dart new file mode 100644 index 0000000..0249d43 --- /dev/null +++ b/lib/models/device_info_model.dart @@ -0,0 +1,68 @@ +// Immutable adatosztály — az eszköz összes statikus adata. +// A DeviceIdentityService tölti be egyszer induláskor. + +class DeviceInfoModel { + // ── Azonosítók ──────────────────────────────────────────────────── + + /// Android rendszerszintű egyedi azonosító. + /// Reinstall után megmarad, factory reset után változik. + final String deviceId; + + /// App-példány UUID — FlutterSecureStorage-ban tárolva. + /// Reinstall után új értéket kap. + final String appInstanceId; + + // ── Hardver ─────────────────────────────────────────────────────── + + final String manufacturer; // "Samsung" + final String model; // "Galaxy Tab S9 Ultra" + final String brand; // "samsung" + + // ── Rendszer ────────────────────────────────────────────────────── + + /// Felhasználó által adott eszköznév (pl. "Pista telefonja"). + /// A Settings.Global.DEVICE_NAME értéke — ez az alapértelmezett label. + final String systemDeviceName; + + final String osVersion; // "14" + final int sdkInt; // 34 + final String securityPatch; // "2024-06-01" + + // ── App ─────────────────────────────────────────────────────────── + + final String appVersion; // "1.0.0" + final String buildNumber; // "15" + + const DeviceInfoModel({ + required this.deviceId, + required this.appInstanceId, + required this.manufacturer, + required this.model, + required this.brand, + required this.systemDeviceName, + required this.osVersion, + required this.sdkInt, + required this.securityPatch, + required this.appVersion, + required this.buildNumber, + }); + + // ── Supabase regisztrációs map ──────────────────────────────────── + + Map toRegistrationMap({required String label}) => { + 'device_id': deviceId, + 'app_instance_id': appInstanceId, + 'label': label, + 'manufacturer': manufacturer, + 'model': model, + 'os_version': osVersion, + 'sdk_int': sdkInt, + 'security_patch': securityPatch, + 'app_version': '$appVersion+$buildNumber', + 'last_seen': DateTime.now().toIso8601String(), + }; + + @override + String toString() => + 'DeviceInfo($manufacturer $model, Android $osVersion, app $appVersion)'; +} diff --git a/lib/models/note_item.dart b/lib/models/note_item.dart index 7630783..a2bba45 100644 --- a/lib/models/note_item.dart +++ b/lib/models/note_item.dart @@ -173,11 +173,11 @@ class NoteItem { ); Polygon toPolygon() => Polygon( - points: points, - color: color.withOpacity(opacity), - borderColor: strokeColor, - borderStrokeWidth: strokeWidth, - label: label.isEmpty ? null : label, - hitValue: id!, - ); + points: points, + color: color.withOpacity(opacity), + borderColor: strokeColor, + borderStrokeWidth: strokeWidth, + label: label.isEmpty ? null : label, + hitValue: id!, + labelStyle: TextStyle(fontSize: 10.0)); } diff --git a/lib/pages/map_survey/presentations/controllers/map_survey_controller.dart b/lib/pages/map_survey/presentations/controllers/map_survey_controller.dart index 345e0a7..17a3e57 100644 --- a/lib/pages/map_survey/presentations/controllers/map_survey_controller.dart +++ b/lib/pages/map_survey/presentations/controllers/map_survey_controller.dart @@ -40,12 +40,14 @@ import 'package:terepi_seged/pages/ntrip_settings/presentation/views/ntrip_setti import 'package:terepi_seged/pages/tracking/presentation/controllers/tracking_controller.dart'; import 'package:terepi_seged/services/app_database.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_device_service.dart'; import 'package:terepi_seged/services/gnss/gnss_service.dart'; import 'package:terepi_seged/services/ntrip_service.dart'; import 'package:terepi_seged/services/project_service.dart'; import 'package:terepi_seged/widgets/map/imported_layer_overlay.dart'; +import 'package:terepi_seged/widgets/map/team_member_widget.dart'; import 'package:terepi_seged/widgets/map_edit_tools/color_row.dart'; import 'package:terepi_seged/widgets/map_edit_tools/map_feature_save_sheet.dart'; import 'package:terepi_seged/widgets/map_edit_tools/note_item_list_sheet.dart'; @@ -190,6 +192,10 @@ class MapSurveyController extends GetxController { final polylineNotes = >[].obs; final polygonNotes = >[].obs; + final teamTrackMarkers = {}.obs; + final teamTrackPoints = >{}.obs; + RealtimeChannel? _teamChannel; + late final PolygonEditorController polygonEditorController; final activeEditColor = const Color(0xFF185FA5).obs; @@ -284,6 +290,8 @@ class MapSurveyController extends GetxController { await _loadNoteItems(); await _loadMeasurePoints(); + + _subscribeToTeamPosition(); } @override @@ -292,6 +300,7 @@ class MapSurveyController extends GetxController { _gnssUpdateSub?.cancel(); final f = _supaChannel?.unsubscribe(); if (f != null) unawaited(f); + unawaited(_teamChannel?.unsubscribe()); pointIdController.dispose(); pointDescriptionController.dispose(); @@ -1915,4 +1924,65 @@ class MapSurveyController extends GetxController { Get.back(); // ← dialóg bezárása, akár sikerült akár nem } } + + void _subscribeToTeamPosition() { + _teamChannel = Supabase.instance.client + .channel('public:terepi_seged_device_positions') + .onPostgresChanges( + event: PostgresChangeEvent.all, + schema: 'public', + table: 'terepi_seged_device_positions', + callback: (payload) => _onTeamUpdate(payload.newRecord)) + .subscribe(); + } + + void _onTeamUpdate(Map data) { + final deviceId = data['device_id'] as String? ?? ''; + if (deviceId.isEmpty) return; + + if (deviceId == DeviceIdentityService.to.deviceId) return; + final isActive = data['is_active'] as bool? ?? true; + + if (!isActive) { + teamTrackMarkers.remove(deviceId); + teamTrackPoints.remove(deviceId); + teamTrackMarkers.refresh(); + teamTrackPoints.refresh(); + return; + } + + final lat = (data['latitude'] as num?)?.toDouble(); + final lon = (data['longitude'] as num?)?.toDouble(); + final name = data['user_name'] as String? ?? deviceId.substring(0, 8); + + if (lat == null || lon == null) return; + + final point = LatLng(lat, lon); + teamTrackMarkers[deviceId] = _buildTeamMarker(point, name, deviceId); + + teamTrackPoints.putIfAbsent(deviceId, () => []); + teamTrackPoints[deviceId]!.add(point); + + teamTrackMarkers.refresh(); + teamTrackPoints.refresh(); + } + + Marker _buildTeamMarker(LatLng point, String name, String deviceId) { + final colors = [ + Colors.blue, + Colors.purple, + Colors.teal, + Colors.indigo, + Colors.cyan, + Colors.deepPurple + ]; + final color = colors[deviceId.hashCode.abs() % colors.length]; + + return Marker( + point: point, + width: 100, + height: 48, + alignment: Alignment.bottomCenter, + child: TeamMemberWidget(name: name, color: color)); + } } diff --git a/lib/pages/map_survey/presentations/views/map_survey_view.dart b/lib/pages/map_survey/presentations/views/map_survey_view.dart index 44d4ee8..6da7d54 100644 --- a/lib/pages/map_survey/presentations/views/map_survey_view.dart +++ b/lib/pages/map_survey/presentations/views/map_survey_view.dart @@ -85,13 +85,37 @@ class MapSurveyView extends GetView { layers: [ const ImportedLayerOverlay(), // Track polyline + Obx(() { + final inTrackMode = controller.mode.value == MapSurveyMode.track; + if (!inTrackMode) return const SizedBox.shrink(); + final ids = TrackingController.to.overlayTrackIds; + if (ids.isEmpty) return const SizedBox.shrink(); + + final polylines = ids + .map((id) { + final pts = TrackingController.to.getCoordsFor(id); + if (pts.isEmpty) return null; + return Polyline( + points: pts, + color: Colors.blue.withOpacity(0.75), + strokeWidth: 2.5, + borderColor: Colors.white.withOpacity(0.4), + borderStrokeWidth: 1.0, + ); + }) + .whereType() + .toList(); + + if (polylines.isEmpty) return const SizedBox.shrink(); + return PolylineLayer(polylines: polylines); + }), Obx(() { final isTracking = TrackingController.to.isRecording.value; final inTrackMode = controller.mode.value == MapSurveyMode.track; if (!isTracking && !inTrackMode) { return const SizedBox.shrink(); } else { - return _buildTrackLayer(); + return _buildTrackLayer1(); } }), Obx(() { @@ -175,10 +199,37 @@ class MapSurveyView extends GetView { NoteItemLabelLayer( controller: controller, ), + Obx(() { + final tracks = controller.teamTrackPoints; + if (tracks.isEmpty) return const SizedBox.shrink(); + return PolylineLayer( + polylines: tracks.entries.map((e) { + final colors = [ + Colors.blue, + Colors.purple, + Colors.teal, + Colors.indigo, + Colors.cyan, + Colors.deepPurple + ]; + final color = colors[e.key.hashCode.abs() % colors.length]; + return Polyline( + points: e.value, + color: color.withValues(alpha: 0.7), + strokeWidth: 2.5); + }).toList(), + ); + }), + Obx(() { + final markers = + List.from(controller.teamTrackMarkers.values); + if (markers.isEmpty) return const SizedBox.shrink(); + return MarkerLayer(markers: markers); + }), Obx(() { final isGpsActive = GnssService.to.activeConnectionType.value != GnssConnectionType.none; - if (isGpsActive) { + if (isGpsActive && controller.mode.value != MapSurveyMode.track) { return MarkerLayer( markers: controller.currentLocationMarker.toList()); } @@ -276,6 +327,43 @@ class MapSurveyView extends GetView { ]); }); } + + Widget _buildTrackLayer1() { + return Obx(() { + final ctrl = TrackingController.to; + final points = ctrl.livePoints.toList(); + if (points.isEmpty) return const SizedBox.shrink(); + + return Stack(children: [ + // 1. Track vonal + PolylineLayer(polylines: [ + Polyline( + points: points, + color: Colors.red.withOpacity(0.85), + strokeWidth: 3.0, + ), + ]), + // 2. Markerek a vonal felett + MarkerLayer(markers: [ + // Kezdőpont — zöld + Marker( + point: points.first, + child: const Icon(Icons.flag, color: Colors.green, size: 28), + ), + // Utolsó pont — piros (ha van legalább 2 pont) + if (points.length > 1) + Marker( + point: points.last, + child: _PulsingDot( + color: TrackingController.to.isPaused.value + ? Colors.orange + : Colors.blue, + ), + ), + ]), + ]); + }); + } } class _ModeSelector extends GetView { @@ -302,6 +390,58 @@ class _ModeSelector extends GetView { } } +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.1) + .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: 8, + height: 8, + 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: 4, + spreadRadius: 2) + ], + ), + ), + ); + } +} + class _StakeoutPanel extends GetView { const _StakeoutPanel(); diff --git a/lib/pages/tracking/presentation/controllers/tracking_controller.dart b/lib/pages/tracking/presentation/controllers/tracking_controller.dart index 3d12c51..9ee115e 100644 --- a/lib/pages/tracking/presentation/controllers/tracking_controller.dart +++ b/lib/pages/tracking/presentation/controllers/tracking_controller.dart @@ -6,6 +6,7 @@ import 'package:intl/intl.dart'; import 'package:latlong2/latlong.dart'; import 'package:share_plus/share_plus.dart'; import 'package:terepi_seged/services/project_service.dart'; +import 'package:terepi_seged/services/track_sync_service.dart'; import '../../../../services/location_source.dart'; import '../../../../services/phone_gps_source.dart'; @@ -150,6 +151,20 @@ class TrackingController extends GetxController { callback: startTrackingCallback, ); + if (!(currentTrack.value?.isLocalOnly ?? true)) { + TrackSyncService.to + .createRemoteTrack(currentTrack.value!) + .then((supabaseId) async { + if (supabaseId == null) return; + final updated = currentTrack.value!.copyWith(supabaseId: supabaseId); + await _db.updateTrack(updated); + currentTrack.value = updated; + TrackSyncService.to.startSession(updated); + }); + } else { + TrackSyncService.to.startSession(currentTrack.value!); + } + // GPS stream feliratkozás _positionSub = _source!.positionStream.listen( _onPosition, @@ -207,6 +222,7 @@ class TrackingController extends GetxController { pointCount: livePoints.length, ); await _db.updateTrack(finished); + await TrackSyncService.to.stopSession(finished); currentTrack.value = finished; } @@ -236,7 +252,7 @@ class TrackingController extends GetxController { try { final path = await _exporter.export(track); await Share.shareXFiles([XFile(path)], - subject: 'Nyomvonal: ${track.name}'); + subject: 'Nyomvonal: ${track.name}.gpx'); } catch (e) { Get.snackbar('Export hiba', e.toString(), backgroundColor: Colors.red, colorText: Colors.white); @@ -289,6 +305,8 @@ class TrackingController extends GetxController { ); await _db.addPoint(point, _accumulatedDistance); + TrackSyncService.to.onNewPoint(point); + _lastPoint = point; // UI frissítés diff --git a/lib/services/device_identity_service.dart b/lib/services/device_identity_service.dart new file mode 100644 index 0000000..bd662f3 --- /dev/null +++ b/lib/services/device_identity_service.dart @@ -0,0 +1,195 @@ +// Eszközazonosítás és eszközinformációk. +// +// Tárolás: +// FlutterSecureStorage → appInstanceId (UUID), deviceLabel +// MethodChannel → ANDROID_ID, rendszer eszköznév +// device_info_plus → gyártó, modell, OS verzió +// package_info_plus → app verzió + +import 'dart:async'; +import 'dart:io'; + +import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:get/get.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:uuid/uuid.dart'; + +import '../models/device_info_model.dart'; + +class DeviceIdentityService extends GetxService { + static DeviceIdentityService get to => Get.find(); + + // ── Konstansok ──────────────────────────────────────────────────── + + static const _channel = MethodChannel('hu.app_dev.terepi_seged/deviceInfo'); + + static const _storage = FlutterSecureStorage( + aOptions: AndroidOptions( + encryptedSharedPreferences: true, // Android Keystore alapú titkosítás + ), + iOptions: IOSOptions( + accessibility: KeychainAccessibility.first_unlock_this_device, + ), + ); + + static const _keyInstanceId = 'device_app_instance_id'; + static const _keyLabel = 'device_label'; + + // ── Publikus mezők ──────────────────────────────────────────────── + + /// Statikus eszközinformációk — egyszer töltődik be, nem változik. + late final DeviceInfoModel info; + + /// Felhasználó által megadott eszköznév — reaktív, szerkeszthető. + /// Alapértelmezett: rendszer eszköznév (pl. "Pista telefonja"). + final deviceLabel = ''.obs; + + bool _isReady = false; + bool get isReady => _isReady; + + // ── Lifecycle ───────────────────────────────────────────────────── + + @override + Future onReady() async { + super.onReady(); + await _load(); + // Háttérben regisztrálás — nem blokkolja az UI-t + unawaited(_registerDevice()); + _isReady = true; + } + + // ── Betöltés ────────────────────────────────────────────────────── + + Future _load() async { + // Párhuzamos lekérdezések az indulás gyorsításához + final results = await Future.wait([ + _getOrCreateInstanceId(), + _getSystemDeviceName(), + _getStoredLabel(), + DeviceInfoPlugin().androidInfo, + PackageInfo.fromPlatform(), + ]); + + final instanceId = results[0] as String; + final systemName = results[1] as String; + final storedLabel = results[2] as String?; + final android = results[3] as AndroidDeviceInfo; + final pkg = results[4] as PackageInfo; + + // ANDROID_ID — MethodChannel-en keresztül (Settings.Secure.ANDROID_ID) + final androidId = await _getAndroidId() ?? android.fingerprint; + print('AndroidId: $androidId'); + + info = DeviceInfoModel( + deviceId: androidId, + appInstanceId: instanceId, + manufacturer: android.manufacturer, + model: android.model, + brand: android.brand, + systemDeviceName: systemName, + osVersion: android.version.release, + sdkInt: android.version.sdkInt, + securityPatch: android.version.securityPatch ?? '', + appVersion: pkg.version, + buildNumber: pkg.buildNumber, + ); + + // Label: tárolt érték > rendszer neve + deviceLabel.value = storedLabel ?? systemName; + print('Device label: ${deviceLabel.value}'); + } + + // ── SecureStorage műveletek ─────────────────────────────────────── + + Future _getOrCreateInstanceId() async { + final existing = await _storage.read(key: _keyInstanceId); + if (existing != null) return existing; + + final newId = const Uuid().v4(); + await _storage.write(key: _keyInstanceId, value: newId); + return newId; + } + + Future _getStoredLabel() => _storage.read(key: _keyLabel); + + // ── MethodChannel hívások ───────────────────────────────────────── + + /// Settings.Secure.ANDROID_ID — egyedi, app-specifikus (Android 8+) + Future _getAndroidId() async { + try { + return await _channel.invokeMethod('getAndroidId'); + } catch (_) { + return null; + } + } + + /// Settings.Global.DEVICE_NAME — felhasználó által adott eszköznév + Future _getSystemDeviceName() async { + try { + final name = await _channel.invokeMethod('getAndroidDeviceName'); + if (name != null && name.isNotEmpty) return name; + } catch (_) {} + // Fallback: gyártó + modell + if (Platform.isAndroid) { + final a = await DeviceInfoPlugin().androidInfo; + return '${a.manufacturer} ${a.model}'; + } + return 'Eszköz'; + } + + // ── Eszköznév beállítása ────────────────────────────────────────── + + /// Felhasználó által megadott eszköznév mentése. + /// Üres string esetén visszaáll a rendszer névhez. + Future setLabel(String label) async { + final trimmed = label.trim(); + + if (trimmed.isEmpty) { + // Visszaállás rendszer névre + await _storage.delete(key: _keyLabel); + deviceLabel.value = info.systemDeviceName; + } else { + await _storage.write(key: _keyLabel, value: trimmed); + deviceLabel.value = trimmed; + } + + unawaited(_registerDevice()); + } + + // ── Supabase regisztráció ───────────────────────────────────────── + + Future _registerDevice() async { + // await Supabase.instance.client + // .from('devices') + // .upsert( + // info.toRegistrationMap(label: deviceLabel.value), + // onConflict: 'device_id', + // ); + } + + // ── Gyors elérők (kényelemért) ──────────────────────────────────── + + String get deviceId => _isReady ? info.deviceId : ''; + String get appInstanceId => + _isReady ? info.appInstanceId : ''; // ← _isReady guard hiányzott + String get deviceLabelSync => deviceLabel.value; // ← ÚJ + String get model => _isReady ? '${info.manufacturer} ${info.model}' : ''; + String get osInfo => + _isReady ? 'Android ${info.osVersion} (SDK ${info.sdkInt})' : ''; + String get appInfo => + _isReady ? '${info.appVersion}+${info.buildNumber}' : ''; + // ── Debug ───────────────────────────────────────────────────────── + + @override + String toString() => [ + 'DeviceIdentityService', + ' deviceId: ${info.deviceId}', + ' instanceId: ${info.appInstanceId}', + ' label: ${deviceLabel.value}', + ' model: $model', + ' os: $osInfo', + ' app: $appInfo', + ].join('\n'); +} diff --git a/lib/services/track_sync_service.dart b/lib/services/track_sync_service.dart new file mode 100644 index 0000000..fd10dd8 --- /dev/null +++ b/lib/services/track_sync_service.dart @@ -0,0 +1,257 @@ +// Kétszintű Supabase szinkronizáció: +// 1. Élő pozíció — minden 3 mp-ben UPSERT → device_positions +// 2. Track pontok — batch INSERT (10 pont vagy 8 mp) → terepi_track_points + +import 'dart:async'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:get/get.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:supabase_flutter/supabase_flutter.dart'; + +import 'app_database.dart'; +import 'device_identity_service.dart'; +import '../models/track.dart'; + +class TrackSyncService extends GetxService { + static TrackSyncService get to => Get.find(); + + final _supabase = Supabase.instance.client; + + // ── Konfiguráció ────────────────────────────────────────────────── + static const _batchSize = 10; + static const _batchIntervalSec = 8; + static const _positionIntervalSec = 3; + + // ── Belső állapot ───────────────────────────────────────────────── + final _buffer = []; + Timer? _batchTimer; + Timer? _posTimer; + + Track? _track; + LatLng? _lastPos; // utoljára kapott pozíció (broadcasthoz) + bool _online = false; + + // ── Publikus állapot ────────────────────────────────────────────── + final isSyncing = false.obs; + final pendingCount = 0.obs; + + // ── Lifecycle ───────────────────────────────────────────────────── + + @override + Future onInit() async { + super.onInit(); + _online = await _checkOnline(); + _listenConnectivity(); + } + + @override + void onClose() { + _stopTimers(); + super.onClose(); + } + + // ── Session vezérlés (TrackingController hívja) ─────────────────── + + void startSession(Track track) { + _track = track; + _buffer.clear(); + pendingCount.value = 0; + + if (track.isLocalOnly) return; + + _batchTimer = Timer.periodic( + const Duration(seconds: _batchIntervalSec), + (_) => _flush(), + ); + _posTimer = Timer.periodic( + const Duration(seconds: _positionIntervalSec), + (_) => _broadcastPosition(), + ); + } + + Future stopSession(Track track) async { + _stopTimers(); + if (track.isLocalOnly) return; + + await _flush(); // utolsó batch + + // Track fejléc lezárása Supabase-ben + if (track.supabaseId != null) { + await _supabase.from('terepi_seged_tracks').update({ + 'end_time': track.endTime?.toIso8601String(), + 'status': 'finished', + 'distance_m': track.distanceMeters, + 'point_count': track.pointCount, + }).eq('id', track.supabaseId!); + } + + await _setInactive(); + _track = null; + _lastPos = null; + } + + // ── Pont pufferelés ─────────────────────────────────────────────── + + /// TrackingController._onPosition() hívja minden pontnál + void onNewPoint(TrackPoint point) { + _lastPos = LatLng(point.latitude, point.longitude); + + if (_track == null || _track!.isLocalOnly) return; + + _buffer.add(point); + pendingCount.value = _buffer.length; + + if (_buffer.length >= _batchSize) _flush(); + } + + // ── Supabase track létrehozása ──────────────────────────────────── + + /// startRecording()-ban hívandó, visszaadja a Supabase UUID-t + Future createRemoteTrack(Track track) async { + if (!_online) return null; + try { + final res = await _supabase + .from('terepi_seged_tracks') + .insert({ + 'device_id': DeviceIdentityService.to.deviceId, + 'name': track.name, + 'source': track.source, + 'start_time': track.startTime.toIso8601String(), + 'status': 'recording', + }) + .select('id') + .single(); + return res['id'] as String?; + } catch (e) { + return null; + } + } + + // ── Batch feltöltés ─────────────────────────────────────────────── + + Future _flush() async { + if (_buffer.isEmpty || !_online) return; + + final supabaseId = _track?.supabaseId; + if (supabaseId == null) return; + + final batch = List.from(_buffer); + _buffer.clear(); + pendingCount.value = 0; + + try { + isSyncing.value = true; + await _supabase.from('terepi_seged_track_points').insert( + batch + .map((p) => { + 'track_id': supabaseId, + 'latitude': p.latitude, + 'longitude': p.longitude, + 'altitude': p.altitude, + 'accuracy': p.accuracy, + 'speed': p.speed, + 'heading': p.heading, + 'timestamp': p.timestamp.toIso8601String(), + }) + .toList(), + ); + } catch (_) { + // Hiba → visszateszi a bufferbe + _buffer.insertAll(0, batch); + pendingCount.value = _buffer.length; + } finally { + isSyncing.value = false; + } + } + + // ── Élő pozíció broadcast ───────────────────────────────────────── + + Future _broadcastPosition() async { + final pos = _lastPos; + if (pos == null || !_online) return; + + final device = DeviceIdentityService.to; + try { + await _supabase.from('terepi_seged_device_positions').upsert({ + 'device_id': device.deviceId, + 'user_name': device.deviceLabel.value, + 'latitude': pos.latitude, + 'longitude': pos.longitude, + 'track_id': _track?.supabaseId, + 'is_active': true, + 'updated_at': DateTime.now().toUtc().toIso8601String(), + }, onConflict: 'device_id'); + } catch (_) {} + } + + Future _setInactive() async { + final deviceId = DeviceIdentityService.to.deviceId; + try { + await _supabase.from('terepi_seged_device_positions').update( + {'is_active': false, 'track_id': null}).eq('device_id', deviceId); + } catch (_) {} + } + + // ── Offline → Online szinkron ───────────────────────────────────── + + Future syncTrack(Track track) async { + if (!_online) return; + + String? supabaseId = track.supabaseId; + if (supabaseId == null) { + supabaseId = await createRemoteTrack(track); + if (supabaseId == null) return; + final updated = track.copyWith(supabaseId: supabaseId); + await AppDatabase.instance.updateTrack(updated); + } + + final points = await AppDatabase.instance.getPoints(track.id!); + if (points.isEmpty) return; + + const chunk = 100; + for (int i = 0; i < points.length; i += chunk) { + final slice = points.sublist(i, (i + chunk).clamp(0, points.length)); + await _supabase.from('terepi_seged_track_points').insert( + slice + .map((p) => { + 'track_id': supabaseId, + 'latitude': p.latitude, + 'longitude': p.longitude, + 'altitude': p.altitude, + 'accuracy': p.accuracy, + 'speed': p.speed, + 'heading': p.heading, + 'timestamp': p.timestamp.toIso8601String(), + }) + .toList(), + ); + } + + await AppDatabase.instance.updateTrack( + track.copyWith(supabaseId: supabaseId), + ); + } + + // ── Kapcsolat figyelés ──────────────────────────────────────────── + + void _listenConnectivity() { + Connectivity().onConnectivityChanged.listen((results) async { + final wasOffline = !_online; + _online = results.any((r) => r != ConnectivityResult.none); + if (wasOffline && _online) await _flush(); + }); + } + + Future _checkOnline() async { + final r = await Connectivity().checkConnectivity(); + return r.any((r) => r != ConnectivityResult.none); + } + + void _stopTimers() { + _batchTimer?.cancel(); + _posTimer?.cancel(); + _batchTimer = null; + _posTimer = null; + } +} diff --git a/lib/widgets/appbar/shell_map_appbar.dart b/lib/widgets/appbar/shell_map_appbar.dart index f5ae46d..3f460b1 100644 --- a/lib/widgets/appbar/shell_map_appbar.dart +++ b/lib/widgets/appbar/shell_map_appbar.dart @@ -127,28 +127,28 @@ class ShellMapAppBar extends StatelessWidget implements PreferredSizeWidget { // ), // ); // }), - Obx(() { - final connected = controller.gpsIsConnected; + // Obx(() { + // final connected = controller.gpsIsConnected; - return IconButton( - tooltip: connected - ? 'GNSS vevő csatlakozva' - : 'GNSS vevő nincs csatlakoztatva', - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - constraints: const BoxConstraints( - minWidth: 40, - minHeight: 44, - ), - icon: GnssReceiverIcon( - size: 24, - color: connected - ? Colors.green.shade700 - : Theme.of(context).colorScheme.onSurfaceVariant, - ), - onPressed: () {}, - ); - }), + // return IconButton( + // tooltip: connected + // ? 'GNSS vevő csatlakozva' + // : 'GNSS vevő nincs csatlakoztatva', + // visualDensity: VisualDensity.compact, + // padding: EdgeInsets.zero, + // constraints: const BoxConstraints( + // minWidth: 40, + // minHeight: 44, + // ), + // icon: GnssReceiverIcon( + // size: 24, + // color: connected + // ? Colors.green.shade700 + // : Theme.of(context).colorScheme.onSurfaceVariant, + // ), + // onPressed: () {}, + // ); + // }), TrackRecordingAction( controller: TrackingController.to, onTap: () => _openTrackingSheet(context), diff --git a/lib/widgets/map/team_member_widget.dart b/lib/widgets/map/team_member_widget.dart new file mode 100644 index 0000000..c3ebd09 --- /dev/null +++ b/lib/widgets/map/team_member_widget.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +class TeamMemberWidget extends StatelessWidget { + final String name; + final Color color; + const TeamMemberWidget({required this.name, required this.color}); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + // Névbuborék + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(5), + boxShadow: [ + BoxShadow( + color: Colors.black26, + blurRadius: 3, + offset: const Offset(0, 1)), + ], + ), + child: Text( + name, + style: const TextStyle( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w600, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + // Nyíl / lokátor ikon + Icon( + Icons.person_pin_circle, + color: color, + size: 24, + shadows: const [ + Shadow(color: Colors.black26, blurRadius: 4), + ], + ), + ], + ); + } +} diff --git a/lib/widgets/shared_map_widgets.dart b/lib/widgets/shared_map_widgets.dart index c64d586..6600aac 100644 --- a/lib/widgets/shared_map_widgets.dart +++ b/lib/widgets/shared_map_widgets.dart @@ -91,7 +91,7 @@ class SharedMapWidget extends StatelessWidget { ), if (controls.showZoomLevel && currentZoom != null) Positioned( - bottom: 150, + bottom: 200, left: 4, child: Obx(() => _ZoomLabel(currentZoom!.value)), ), diff --git a/pubspec.yaml b/pubspec.yaml index ce4c3b7..f68ffa0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -77,6 +77,8 @@ dependencies: audioplayers: ^6.7.1 archive: ^4.0.9 xml: ^7.0.1 + shared_preferences: ^2.5.5 + device_info_plus: ^12.4.0 flutter: sdk: flutter