Online tracking, deviceidentityservice

This commit is contained in:
torok.istvan 2026-06-23 15:21:20 +02:00
parent 65b355edd9
commit aa78c7bb6f
13 changed files with 850 additions and 33 deletions

View File

@ -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
}
}
}

View File

@ -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<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
@ -36,6 +38,8 @@ Future<void> 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());
}

View File

@ -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<String, dynamic> 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)';
}

View File

@ -173,11 +173,11 @@ class NoteItem {
);
Polygon<int> 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));
}

View File

@ -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 = <Polyline<int>>[].obs;
final polygonNotes = <Polygon<int>>[].obs;
final teamTrackMarkers = <String, Marker>{}.obs;
final teamTrackPoints = <String, List<LatLng>>{}.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<String, dynamic> 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));
}
}

View File

@ -85,13 +85,37 @@ class MapSurveyView extends GetView<MapSurveyController> {
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<Polyline>()
.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<MapSurveyController> {
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<Marker>.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<MapSurveyController> {
]);
});
}
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<MapSurveyController> {
@ -302,6 +390,58 @@ class _ModeSelector extends GetView<MapSurveyController> {
}
}
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<double> _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<MapSurveyController> {
const _StakeoutPanel();

View File

@ -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

View File

@ -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<void> 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<void> _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<String> _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<String?> _getStoredLabel() => _storage.read(key: _keyLabel);
// MethodChannel hívások
/// Settings.Secure.ANDROID_ID egyedi, app-specifikus (Android 8+)
Future<String?> _getAndroidId() async {
try {
return await _channel.invokeMethod<String>('getAndroidId');
} catch (_) {
return null;
}
}
/// Settings.Global.DEVICE_NAME felhasználó által adott eszköznév
Future<String> _getSystemDeviceName() async {
try {
final name = await _channel.invokeMethod<String>('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<void> 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<void> _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');
}

View File

@ -0,0 +1,257 @@
// Kétszintű Supabase szinkronizáció:
// 1. É 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 = <TrackPoint>[];
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<void> 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<void> 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<String?> 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<void> _flush() async {
if (_buffer.isEmpty || !_online) return;
final supabaseId = _track?.supabaseId;
if (supabaseId == null) return;
final batch = List<TrackPoint>.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;
}
}
// É pozíció broadcast
Future<void> _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<void> _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<void> 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<bool> _checkOnline() async {
final r = await Connectivity().checkConnectivity();
return r.any((r) => r != ConnectivityResult.none);
}
void _stopTimers() {
_batchTimer?.cancel();
_posTimer?.cancel();
_batchTimer = null;
_posTimer = null;
}
}

View File

@ -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),

View File

@ -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),
],
),
],
);
}
}

View File

@ -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)),
),

View File

@ -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