Online tracking, deviceidentityservice
This commit is contained in:
parent
65b355edd9
commit
aa78c7bb6f
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -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());
|
||||
}
|
||||
|
||||
68
lib/models/device_info_model.dart
Normal file
68
lib/models/device_info_model.dart
Normal 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)';
|
||||
}
|
||||
@ -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));
|
||||
}
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
195
lib/services/device_identity_service.dart
Normal file
195
lib/services/device_identity_service.dart
Normal 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');
|
||||
}
|
||||
257
lib/services/track_sync_service.dart
Normal file
257
lib/services/track_sync_service.dart
Normal file
@ -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 = <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;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Élő 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;
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
|
||||
49
lib/widgets/map/team_member_widget.dart
Normal file
49
lib/widgets/map/team_member_widget.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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)),
|
||||
),
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user