From 35ddc8bba0eaea5f35a1b2d21fd90976df50b162 Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 10 Jun 2026 18:39:35 +0300 Subject: [PATCH 01/40] feat(location): add reusable LocationService with on-demand and streaming GPS access --- lib/locator.dart | 5 + .../services/location/i_location_service.dart | 22 ++ .../services/location/location_service.dart | 98 ++++++ .../services/location/model/geo_position.dart | 15 + .../location/model/geo_position.freezed.dart | 280 ++++++++++++++++++ .../services/location/model/location_fix.dart | 38 +++ 6 files changed, 458 insertions(+) create mode 100644 lib/utils/services/location/i_location_service.dart create mode 100644 lib/utils/services/location/location_service.dart create mode 100644 lib/utils/services/location/model/geo_position.dart create mode 100644 lib/utils/services/location/model/geo_position.freezed.dart create mode 100644 lib/utils/services/location/model/location_fix.dart diff --git a/lib/locator.dart b/lib/locator.dart index 0fb8b408..38949385 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -18,6 +18,8 @@ import 'package:thingsboard_app/utils/services/loading_service/i_loading_service import 'package:thingsboard_app/utils/services/loading_service/loading_service.dart'; import 'package:thingsboard_app/utils/services/local_database/i_local_database_service.dart'; import 'package:thingsboard_app/utils/services/local_database/local_database_service.dart'; +import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; +import 'package:thingsboard_app/utils/services/location/location_service.dart'; import 'package:thingsboard_app/utils/services/notification_service.dart'; import 'package:thingsboard_app/utils/services/overlay_service/i_overlay_service.dart'; import 'package:thingsboard_app/utils/services/overlay_service/overlay_service.dart'; @@ -54,6 +56,9 @@ Future setUpRootDependencies() async { ..registerLazySingleton(() => TbImageGalleryService()) ..registerLazySingleton(() => ThingsboardAppRouter(overlayService: getIt())) ..registerLazySingleton(() => deviceInfoService) + ..registerLazySingleton( + () => LocationService(logger: getIt()), + ) // ..registerLazySingleton(() => TbContext()) ..registerSingletonAsync(() async { final client = TbClientService(); diff --git a/lib/utils/services/location/i_location_service.dart b/lib/utils/services/location/i_location_service.dart new file mode 100644 index 00000000..f6d1b291 --- /dev/null +++ b/lib/utils/services/location/i_location_service.dart @@ -0,0 +1,22 @@ +import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; + +/// Single entry point for GPS access across the app. Implementations own all +/// permission / service-enabled handling so callers never touch the geolocator +/// plugin directly. +abstract interface class ILocationService { + /// Resolves a single current position, performing the full permission and + /// service-enabled checks internally. + Future getCurrentPosition(); + + /// Foreground live position updates. Pre-checks availability, then relays + /// each update as a [LocationSuccess]. Emits a terminal failure [LocationFix] + /// (and stops) if location is unavailable. Subscribers must cancel their + /// [StreamSubscription] when done (Riverpod/Bloc disposal handles this). + Stream positionStream({double distanceFilterMeters = 0}); + + /// Opens the OS location settings screen. Returns true if it was opened. + Future openLocationSettings(); + + /// Opens this app's settings screen (for permanently-denied permission). + Future openAppSettings(); +} diff --git a/lib/utils/services/location/location_service.dart b/lib/utils/services/location/location_service.dart new file mode 100644 index 00000000..04fef0b2 --- /dev/null +++ b/lib/utils/services/location/location_service.dart @@ -0,0 +1,98 @@ +import 'dart:async'; + +import 'package:geolocator/geolocator.dart'; +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; +import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; + +class LocationService implements ILocationService { + LocationService({required TbLogger logger, GeolocatorPlatform? geolocator}) + : _log = logger, + _geolocator = geolocator ?? GeolocatorPlatform.instance; + + final TbLogger _log; + final GeolocatorPlatform _geolocator; + + @override + Future getCurrentPosition() async { + try { + final unavailable = await _ensureAvailable(); + if (unavailable != null) { + return unavailable; + } + + final position = await _geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + return LocationSuccess(_toGeoPosition(position)); + } catch (e, s) { + _log.error('LocationService.getCurrentPosition failed', e, s); + return LocationFixError(e.toString()); + } + } + + @override + Stream positionStream({double distanceFilterMeters = 0}) async* { + final unavailable = await _ensureAvailable(); + if (unavailable != null) { + yield unavailable; + return; + } + + final raw = _geolocator.getPositionStream( + locationSettings: LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: distanceFilterMeters.round(), + ), + ); + + yield* raw.transform( + StreamTransformer.fromHandlers( + handleData: + (position, sink) => + sink.add(LocationSuccess(_toGeoPosition(position))), + handleError: (e, s, sink) { + _log.error('LocationService.positionStream error', e, s); + sink.add(LocationFixError(e.toString())); + }, + ), + ); + } + + @override + Future openLocationSettings() => _geolocator.openLocationSettings(); + + @override + Future openAppSettings() => _geolocator.openAppSettings(); + + /// Returns `null` when location is available, otherwise a failure + /// [LocationFix] describing why it is not. + Future _ensureAvailable() async { + final serviceEnabled = await _geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + return const LocationServicesDisabled(); + } + + var permission = await _geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await _geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + return const LocationPermissionDenied(); + } + } + if (permission == LocationPermission.deniedForever) { + return const LocationPermissionDeniedForever(); + } + return null; + } + + GeoPosition _toGeoPosition(Position p) => GeoPosition( + latitude: p.latitude, + longitude: p.longitude, + accuracy: p.accuracy, + timestamp: p.timestamp, + ); +} diff --git a/lib/utils/services/location/model/geo_position.dart b/lib/utils/services/location/model/geo_position.dart new file mode 100644 index 00000000..645c8cc3 --- /dev/null +++ b/lib/utils/services/location/model/geo_position.dart @@ -0,0 +1,15 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'geo_position.freezed.dart'; + +/// Plugin-agnostic GPS position. Keeps `package:geolocator`'s `Position` +/// type from leaking past the location service boundary. +@freezed +abstract class GeoPosition with _$GeoPosition { + const factory GeoPosition({ + required double latitude, + required double longitude, + required double accuracy, + DateTime? timestamp, + }) = _GeoPosition; +} diff --git a/lib/utils/services/location/model/geo_position.freezed.dart b/lib/utils/services/location/model/geo_position.freezed.dart new file mode 100644 index 00000000..4a5aefc3 --- /dev/null +++ b/lib/utils/services/location/model/geo_position.freezed.dart @@ -0,0 +1,280 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'geo_position.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$GeoPosition { + + double get latitude; double get longitude; double get accuracy; DateTime? get timestamp; +/// Create a copy of GeoPosition +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$GeoPositionCopyWith get copyWith => _$GeoPositionCopyWithImpl(this as GeoPosition, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is GeoPosition&&(identical(other.latitude, latitude) || other.latitude == latitude)&&(identical(other.longitude, longitude) || other.longitude == longitude)&&(identical(other.accuracy, accuracy) || other.accuracy == accuracy)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)); +} + + +@override +int get hashCode => Object.hash(runtimeType,latitude,longitude,accuracy,timestamp); + +@override +String toString() { + return 'GeoPosition(latitude: $latitude, longitude: $longitude, accuracy: $accuracy, timestamp: $timestamp)'; +} + + +} + +/// @nodoc +abstract mixin class $GeoPositionCopyWith<$Res> { + factory $GeoPositionCopyWith(GeoPosition value, $Res Function(GeoPosition) _then) = _$GeoPositionCopyWithImpl; +@useResult +$Res call({ + double latitude, double longitude, double accuracy, DateTime? timestamp +}); + + + + +} +/// @nodoc +class _$GeoPositionCopyWithImpl<$Res> + implements $GeoPositionCopyWith<$Res> { + _$GeoPositionCopyWithImpl(this._self, this._then); + + final GeoPosition _self; + final $Res Function(GeoPosition) _then; + +/// Create a copy of GeoPosition +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? latitude = null,Object? longitude = null,Object? accuracy = null,Object? timestamp = freezed,}) { + return _then(_self.copyWith( +latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable +as double,longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable +as double,accuracy: null == accuracy ? _self.accuracy : accuracy // ignore: cast_nullable_to_non_nullable +as double,timestamp: freezed == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [GeoPosition]. +extension GeoPositionPatterns on GeoPosition { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _GeoPosition value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _GeoPosition() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _GeoPosition value) $default,){ +final _that = this; +switch (_that) { +case _GeoPosition(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _GeoPosition value)? $default,){ +final _that = this; +switch (_that) { +case _GeoPosition() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( double latitude, double longitude, double accuracy, DateTime? timestamp)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _GeoPosition() when $default != null: +return $default(_that.latitude,_that.longitude,_that.accuracy,_that.timestamp);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( double latitude, double longitude, double accuracy, DateTime? timestamp) $default,) {final _that = this; +switch (_that) { +case _GeoPosition(): +return $default(_that.latitude,_that.longitude,_that.accuracy,_that.timestamp);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( double latitude, double longitude, double accuracy, DateTime? timestamp)? $default,) {final _that = this; +switch (_that) { +case _GeoPosition() when $default != null: +return $default(_that.latitude,_that.longitude,_that.accuracy,_that.timestamp);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _GeoPosition implements GeoPosition { + const _GeoPosition({required this.latitude, required this.longitude, required this.accuracy, this.timestamp}); + + +@override final double latitude; +@override final double longitude; +@override final double accuracy; +@override final DateTime? timestamp; + +/// Create a copy of GeoPosition +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$GeoPositionCopyWith<_GeoPosition> get copyWith => __$GeoPositionCopyWithImpl<_GeoPosition>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _GeoPosition&&(identical(other.latitude, latitude) || other.latitude == latitude)&&(identical(other.longitude, longitude) || other.longitude == longitude)&&(identical(other.accuracy, accuracy) || other.accuracy == accuracy)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)); +} + + +@override +int get hashCode => Object.hash(runtimeType,latitude,longitude,accuracy,timestamp); + +@override +String toString() { + return 'GeoPosition(latitude: $latitude, longitude: $longitude, accuracy: $accuracy, timestamp: $timestamp)'; +} + + +} + +/// @nodoc +abstract mixin class _$GeoPositionCopyWith<$Res> implements $GeoPositionCopyWith<$Res> { + factory _$GeoPositionCopyWith(_GeoPosition value, $Res Function(_GeoPosition) _then) = __$GeoPositionCopyWithImpl; +@override @useResult +$Res call({ + double latitude, double longitude, double accuracy, DateTime? timestamp +}); + + + + +} +/// @nodoc +class __$GeoPositionCopyWithImpl<$Res> + implements _$GeoPositionCopyWith<$Res> { + __$GeoPositionCopyWithImpl(this._self, this._then); + + final _GeoPosition _self; + final $Res Function(_GeoPosition) _then; + +/// Create a copy of GeoPosition +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? latitude = null,Object? longitude = null,Object? accuracy = null,Object? timestamp = freezed,}) { + return _then(_GeoPosition( +latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable +as double,longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable +as double,accuracy: null == accuracy ? _self.accuracy : accuracy // ignore: cast_nullable_to_non_nullable +as double,timestamp: freezed == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + + +} + +// dart format on diff --git a/lib/utils/services/location/model/location_fix.dart b/lib/utils/services/location/model/location_fix.dart new file mode 100644 index 00000000..84315e8b --- /dev/null +++ b/lib/utils/services/location/model/location_fix.dart @@ -0,0 +1,38 @@ +import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; + +/// Outcome of a location request. Exhaustively matched with `switch`, so every +/// caller is forced by the compiler to handle each failure mode. +sealed class LocationFix { + const LocationFix(); +} + +/// A position was obtained. +final class LocationSuccess extends LocationFix { + const LocationSuccess(this.position); + + final GeoPosition position; +} + +/// The OS location services are turned off. Caller may prompt the user and +/// call [ILocationService.openLocationSettings]. +final class LocationServicesDisabled extends LocationFix { + const LocationServicesDisabled(); +} + +/// Permission was denied but can be requested again later. +final class LocationPermissionDenied extends LocationFix { + const LocationPermissionDenied(); +} + +/// Permission was permanently denied. Caller must deep-link via +/// [ILocationService.openAppSettings]. +final class LocationPermissionDeniedForever extends LocationFix { + const LocationPermissionDeniedForever(); +} + +/// An unexpected platform error occurred. +final class LocationFixError extends LocationFix { + const LocationFixError(this.message); + + final String message; +} From e08b53aa590974143b5bf33f1cdb75ba32bc3f53 Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 10 Jun 2026 18:39:35 +0300 Subject: [PATCH 02/40] refactor(location): delegate GetLocationAction to LocationService via shared result mapper --- .../actions/get_location_action.dart | 61 ++++--------------- .../location_action_result_mapper.dart | 29 +++++++++ 2 files changed, 40 insertions(+), 50 deletions(-) create mode 100644 lib/utils/services/mobile_actions/actions/location_action_result_mapper.dart diff --git a/lib/utils/services/mobile_actions/actions/get_location_action.dart b/lib/utils/services/mobile_actions/actions/get_location_action.dart index 7a954226..4454fbde 100644 --- a/lib/utils/services/mobile_actions/actions/get_location_action.dart +++ b/lib/utils/services/mobile_actions/actions/get_location_action.dart @@ -1,64 +1,25 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:geolocator/geolocator.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/actions/location_action_result_mapper.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action.dart'; -import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action_result.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_result.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_type.dart'; -class GetLocationAction extends MobileAction { - Future _checkService() async { - final serviceEnabled = await Geolocator.isLocationServiceEnabled(); - - if (!serviceEnabled) { - if (!await Geolocator.openLocationSettings()) { - return false; - } - return _checkService(); - } - return true; - } - - Future _getLocation() async { +class GetLocationAction extends MobileAction with LocationActionResultMapper { + @override + Future execute( + List args, + InAppWebViewController controller, + ) async { try { - final serviceEnabled = await _checkService(); - if (!serviceEnabled) { - return WidgetMobileActionResult.errorResult( - 'Location services are disabled.', - ); - } - LocationPermission permission; - - permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied) { - permission = await Geolocator.requestPermission(); - if (permission == LocationPermission.denied) { - return WidgetMobileActionResult.errorResult( - 'Location permissions are denied.', - ); - } - } - if (permission == LocationPermission.deniedForever) { - return WidgetMobileActionResult.errorResult( - 'Location permissions are permanently denied, we cannot request permissions.', - ); - } - final position = await Geolocator.getCurrentPosition( - desiredAccuracy: LocationAccuracy.high, - ); - return WidgetMobileActionResult.successResult( - MobileActionResult.location(position.latitude, position.longitude), - ); + final fix = await getIt().getCurrentPosition(); + return mapLocationFixToResult(fix); } catch (e) { return handleError(e); } } - @override - Future> execute( - List args, InAppWebViewController controller,) { - return _getLocation(); - } - @override WidgetMobileActionType get type => WidgetMobileActionType.getLocation; } diff --git a/lib/utils/services/mobile_actions/actions/location_action_result_mapper.dart b/lib/utils/services/mobile_actions/actions/location_action_result_mapper.dart new file mode 100644 index 00000000..88189668 --- /dev/null +++ b/lib/utils/services/mobile_actions/actions/location_action_result_mapper.dart @@ -0,0 +1,29 @@ +import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action_result.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_result.dart'; + +/// Shared mapping of a [LocationFix] to a [WidgetMobileActionResult], used by +/// both the one-shot `GetLocationAction` and the live `GetLiveLocationAction` +/// so the success/failure result contract stays identical between them. +mixin LocationActionResultMapper { + WidgetMobileActionResult mapLocationFixToResult(LocationFix fix) { + return switch (fix) { + LocationSuccess(:final position) => + WidgetMobileActionResult.successResult( + MobileActionResult.location(position.latitude, position.longitude), + ), + LocationServicesDisabled() => WidgetMobileActionResult.errorResult( + 'Location services are disabled.', + ), + LocationPermissionDenied() => WidgetMobileActionResult.errorResult( + 'Location permissions are denied.', + ), + LocationPermissionDeniedForever() => WidgetMobileActionResult.errorResult( + 'Location permissions are permanently denied, we cannot request permissions.', + ), + LocationFixError(:final message) => WidgetMobileActionResult.errorResult( + message, + ), + }; + } +} From 7e039d7827cad9483872607365244464d61c8c23 Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 10 Jun 2026 18:39:35 +0300 Subject: [PATCH 03/40] feat(location): add GetLiveLocationAction backed by positionStream --- .../actions/get_live_location_action.dart | 83 +++++++++++++++++++ .../mobile_actions/widget_action_handler.dart | 16 ++-- .../widget_mobile_action_type.dart | 1 + 3 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 lib/utils/services/mobile_actions/actions/get_live_location_action.dart diff --git a/lib/utils/services/mobile_actions/actions/get_live_location_action.dart b/lib/utils/services/mobile_actions/actions/get_live_location_action.dart new file mode 100644 index 00000000..7b1ea67e --- /dev/null +++ b/lib/utils/services/mobile_actions/actions/get_live_location_action.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:thingsboard_app/config/routes/v2/router_2.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/actions/location_action_result_mapper.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_result.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_type.dart'; + +/// Live counterpart of [WidgetMobileActionType.getLocation]: subscribes to +/// [ILocationService.positionStream] and reflects the device position as it +/// updates, rather than returning a single fix. +/// +/// NOTE: the dialog below is a placeholder visualization used to exercise the +/// live stream — the production UI is not decided yet. The action is registered +/// under [WidgetMobileActionType.getLiveLocation] but is not yet triggered by +/// any dashboard widget. +class GetLiveLocationAction extends MobileAction + with LocationActionResultMapper { + @override + Future execute( + List args, + InAppWebViewController controller, + ) async { + try { + final service = getIt(); + final context = globalNavigatorKey.currentContext!; + + // Most recent successful fix, handed back when the dialog is dismissed. + LocationFix? lastFix; + + await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('Live location'), + content: StreamBuilder( + stream: service.positionStream(), + builder: (ctx, snapshot) { + final fix = snapshot.data; + if (fix is LocationSuccess) { + lastFix = fix; + } + return Text(_describe(fix)); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Close'), + ), + ], + ); + }, + ); + + return lastFix == null + ? WidgetMobileActionResult.emptyResult() + : mapLocationFixToResult(lastFix!); + } catch (e) { + return handleError(e); + } + } + + String _describe(LocationFix? fix) => switch (fix) { + null => 'Waiting for first GPS fix…', + LocationSuccess(:final position) => + 'lat: ${position.latitude.toStringAsFixed(6)}\n' + 'lng: ${position.longitude.toStringAsFixed(6)}\n' + 'accuracy: ${position.accuracy.toStringAsFixed(1)} m\n' + 'updated: ${position.timestamp}', + LocationServicesDisabled() => 'Location services are disabled.', + LocationPermissionDenied() => 'Location permission denied.', + LocationPermissionDeniedForever() => + 'Location permission permanently denied.', + LocationFixError(:final message) => 'Error: $message', + }; + + @override + WidgetMobileActionType get type => WidgetMobileActionType.getLiveLocation; +} diff --git a/lib/utils/services/mobile_actions/widget_action_handler.dart b/lib/utils/services/mobile_actions/widget_action_handler.dart index efb881b2..4d49afbc 100644 --- a/lib/utils/services/mobile_actions/widget_action_handler.dart +++ b/lib/utils/services/mobile_actions/widget_action_handler.dart @@ -1,6 +1,6 @@ - import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/device_provisioning_action.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/actions/get_live_location_action.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/get_location_action.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/make_phone_call_action.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/scan_qr_action.dart'; @@ -12,8 +12,9 @@ import 'package:thingsboard_app/utils/services/mobile_actions/actions/take_scree import 'package:thingsboard_app/utils/services/mobile_actions/actions/unknown_action.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_result.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_type.dart'; + class WidgetActionHandler { - static final actions = [ + static final actions = [ DeviceProvisioningAction(), UnknownAction(), ShowMapLocationAction(), @@ -23,6 +24,7 @@ class WidgetActionHandler { ScanQrAction(), MakePhoneCallAction(), GetLocationAction(), + GetLiveLocationAction(), TakeScreenshotAction(), ]; Future> handleWidgetMobileAction( @@ -43,19 +45,11 @@ class WidgetActionHandler { (action) => action.type == actionType, orElse: () => UnknownAction(), ); - return await actionToCall.execute( - args, - controller, - ); + return await actionToCall.execute(args, controller); } else { return WidgetMobileActionResult.errorResult( 'actionType is not provided.', ); } } - - - - - } diff --git a/lib/utils/services/mobile_actions/widget_mobile_action_type.dart b/lib/utils/services/mobile_actions/widget_mobile_action_type.dart index cced4aab..420d0892 100644 --- a/lib/utils/services/mobile_actions/widget_mobile_action_type.dart +++ b/lib/utils/services/mobile_actions/widget_mobile_action_type.dart @@ -6,6 +6,7 @@ enum WidgetMobileActionType { scanQrCode, makePhoneCall, getLocation, + getLiveLocation, takeScreenshot, deviceProvision, unknown; From 9ac19685110cb183e8fd21f77f03b8a7b9216d0e Mon Sep 17 00:00:00 2001 From: ababak Date: Fri, 3 Jul 2026 14:35:24 +0300 Subject: [PATCH 04/40] feat(location): add background-capable position stream and tracking spike page - positionStream now accepts LocationStreamSettings (accuracy tier, distance filter, interval, optional background mode) - background mode uses geolocator foreground service notification on Android and background location updates on iOS - add FOREGROUND_SERVICE / FOREGROUND_SERVICE_LOCATION permissions and the iOS 'location' background mode - add debug-only live tracking spike page (More menu) that streams fixes and saves latitude/longitude/gpsAccuracy telemetry to the current user entity --- android/app/src/main/AndroidManifest.xml | 4 + ios/Runner/Info-Debug.plist | 1 + ios/Runner/Info-Release.plist | 1 + .../routes/location_tracking_routes.dart | 15 + .../v2/routes_config/routes/main_routes.dart | 3 +- .../view/live_tracking_spike_page.dart | 283 ++++++++++++++++++ lib/modules/more/more_page.dart | 79 +++-- .../services/location/i_location_service.dart | 15 +- .../services/location/location_service.dart | 47 ++- .../model/location_stream_settings.dart | 36 +++ 10 files changed, 441 insertions(+), 43 deletions(-) create mode 100644 lib/config/routes/v2/routes_config/routes/location_tracking_routes.dart create mode 100644 lib/modules/location_tracking/presentation/view/live_tracking_spike_page.dart create mode 100644 lib/utils/services/location/model/location_stream_settings.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 92db044b..3c8b5671 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -15,6 +15,10 @@ + + + + UIBackgroundModes fetch + location remote-notification UILaunchStoryboardName diff --git a/ios/Runner/Info-Release.plist b/ios/Runner/Info-Release.plist index f612e4cc..3d493f48 100644 --- a/ios/Runner/Info-Release.plist +++ b/ios/Runner/Info-Release.plist @@ -59,6 +59,7 @@ UIBackgroundModes fetch + location remote-notification UILaunchStoryboardName diff --git a/lib/config/routes/v2/routes_config/routes/location_tracking_routes.dart b/lib/config/routes/v2/routes_config/routes/location_tracking_routes.dart new file mode 100644 index 00000000..11359e1d --- /dev/null +++ b/lib/config/routes/v2/routes_config/routes/location_tracking_routes.dart @@ -0,0 +1,15 @@ +import 'package:go_router/go_router.dart'; +import 'package:thingsboard_app/modules/location_tracking/presentation/view/live_tracking_spike_page.dart'; + +class LocationTrackingRoutes { + static const liveTrackingSpike = '/liveTrackingSpike'; +} + +final List locationTrackingRoutes = [ + GoRoute( + path: LocationTrackingRoutes.liveTrackingSpike, + builder: (context, state) { + return const LiveTrackingSpikePage(); + }, + ), +]; diff --git a/lib/config/routes/v2/routes_config/routes/main_routes.dart b/lib/config/routes/v2/routes_config/routes/main_routes.dart index 596df67b..e19b2bb5 100644 --- a/lib/config/routes/v2/routes_config/routes/main_routes.dart +++ b/lib/config/routes/v2/routes_config/routes/main_routes.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:thingsboard_app/config/routes/v2/routes_config/routes/alarm_routes.dart'; import 'package:thingsboard_app/config/routes/v2/routes_config/routes/asset_routes.dart'; import 'package:thingsboard_app/config/routes/v2/routes_config/routes/audit_log_routes.dart'; @@ -9,6 +8,7 @@ import 'package:thingsboard_app/config/routes/v2/routes_config/routes/dashboard_ import 'package:thingsboard_app/config/routes/v2/routes_config/routes/device_routes.dart'; import 'package:thingsboard_app/config/routes/v2/routes_config/routes/esp_provisioning_routes.dart'; import 'package:thingsboard_app/config/routes/v2/routes_config/routes/home_routes.dart'; +import 'package:thingsboard_app/config/routes/v2/routes_config/routes/location_tracking_routes.dart'; import 'package:thingsboard_app/config/routes/v2/routes_config/routes/more_routes.dart'; import 'package:thingsboard_app/config/routes/v2/routes_config/routes/notification_routes.dart'; import 'package:thingsboard_app/config/routes/v2/routes_config/routes/profile_routes.dart'; @@ -28,6 +28,7 @@ final allMainPages = [ ...urlRoutes, ...espProvisioningRoutes, ...profileRoutes, + ...locationTrackingRoutes, ]; List getMainRoutes() { return [ diff --git a/lib/modules/location_tracking/presentation/view/live_tracking_spike_page.dart b/lib/modules/location_tracking/presentation/view/live_tracking_spike_page.dart new file mode 100644 index 00000000..a31142d6 --- /dev/null +++ b/lib/modules/location_tracking/presentation/view/live_tracking_spike_page.dart @@ -0,0 +1,283 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; +import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_stream_settings.dart'; +import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; + +/// Phase 1a spike (see docs/superpowers/specs/2026-07-03-gps-tracking-design.md): +/// validates background/foreground live tracking and REST telemetry saves on +/// real devices before the production tracking service is built. Debug-only +/// entry point on the More page; throwaway UI by design. +class LiveTrackingSpikePage extends StatefulWidget { + const LiveTrackingSpikePage({super.key}); + + @override + State createState() => _LiveTrackingSpikePageState(); +} + +class _SpikeEvent { + _SpikeEvent(this.message, {this.isError = false}) : time = DateTime.now(); + + final DateTime time; + final String message; + final bool isError; +} + +class _LiveTrackingSpikePageState extends State { + static const _maxEvents = 200; + + bool _backgroundMode = true; + bool _saveTelemetry = true; + + StreamSubscription? _subscription; + Timer? _ticker; + DateTime? _startedAt; + int _fixCount = 0; + int _savedCount = 0; + int _saveErrorCount = 0; + GeoPosition? _lastFix; + final List<_SpikeEvent> _events = []; + + bool get _isRunning => _subscription != null; + + @override + void dispose() { + _ticker?.cancel(); + _subscription?.cancel(); + super.dispose(); + } + + void _start() { + final settings = LocationStreamSettings( + background: + _backgroundMode + ? const BackgroundTrackingConfig( + notificationTitle: 'ThingsBoard live tracking', + notificationText: 'Sharing phone location (spike)', + ) + : null, + ); + + _startedAt = DateTime.now(); + _fixCount = 0; + _savedCount = 0; + _saveErrorCount = 0; + _lastFix = null; + _events.clear(); + _logEvent( + 'Started (${_backgroundMode ? 'background' : 'foreground'} mode, ' + 'telemetry ${_saveTelemetry ? 'on' : 'off'})', + ); + + _subscription = getIt() + .positionStream(settings: settings) + .listen(_onFix, onDone: _stop); + _ticker = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() {}); + }); + setState(() {}); + } + + void _stop() { + _ticker?.cancel(); + _ticker = null; + _subscription?.cancel(); + _subscription = null; + _logEvent('Stopped'); + if (mounted) setState(() {}); + } + + void _onFix(LocationFix fix) { + switch (fix) { + case LocationSuccess(:final position): + _fixCount++; + _lastFix = position; + _logEvent( + '${position.latitude.toStringAsFixed(6)}, ' + '${position.longitude.toStringAsFixed(6)} ' + '(±${position.accuracy.toStringAsFixed(0)} m)', + ); + if (_saveTelemetry) { + unawaited(_saveFix(position)); + } + case LocationServicesDisabled(): + _logEvent('Location services disabled', isError: true); + _stop(); + case LocationPermissionDenied(): + _logEvent('Location permission denied', isError: true); + _stop(); + case LocationPermissionDeniedForever(): + _logEvent('Location permission permanently denied', isError: true); + _stop(); + case LocationFixError(:final message): + _logEvent('Fix error: $message', isError: true); + } + if (mounted) setState(() {}); + } + + Future _saveFix(GeoPosition position) async { + try { + final client = getIt().client; + final userId = client.getAuthUser()?.userId; + if (userId == null) { + throw Exception('No authenticated user'); + } + + await client.getTelemetryControllerApi().saveEntityTelemetry( + entityType: 'USER', + entityId: userId, + scope: 'ANY', + body: jsonEncode({ + 'ts': (position.timestamp ?? DateTime.now()).millisecondsSinceEpoch, + 'values': { + 'latitude': position.latitude, + 'longitude': position.longitude, + 'gpsAccuracy': position.accuracy, + }, + }), + ); + _savedCount++; + } catch (e) { + _saveErrorCount++; + _logEvent('Telemetry save failed: $e', isError: true); + } + if (mounted) setState(() {}); + } + + void _logEvent(String message, {bool isError = false}) { + _events.insert(0, _SpikeEvent(message, isError: isError)); + if (_events.length > _maxEvents) { + _events.removeLast(); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('GPS tracking spike')), + body: Column( + children: [ + SwitchListTile( + title: const Text('Background tracking'), + subtitle: const Text( + 'Foreground service on Android, background mode on iOS', + ), + value: _backgroundMode, + onChanged: + _isRunning + ? null + : (value) => setState(() => _backgroundMode = value), + ), + SwitchListTile( + title: const Text('Save telemetry to my user entity'), + subtitle: const Text('latitude / longitude / gpsAccuracy'), + value: _saveTelemetry, + onChanged: + _isRunning + ? null + : (value) => setState(() => _saveTelemetry = value), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Expanded( + child: FilledButton.icon( + onPressed: _isRunning ? _stop : _start, + icon: Icon(_isRunning ? Icons.stop : Icons.play_arrow), + label: Text(_isRunning ? 'Stop' : 'Start'), + ), + ), + ], + ), + ), + _StatsCard( + isRunning: _isRunning, + startedAt: _startedAt, + fixCount: _fixCount, + savedCount: _savedCount, + saveErrorCount: _saveErrorCount, + lastFix: _lastFix, + ), + const Divider(height: 1), + Expanded( + child: ListView.builder( + itemCount: _events.length, + itemBuilder: (context, index) { + final event = _events[index]; + return ListTile( + dense: true, + leading: Text( + TimeOfDay.fromDateTime(event.time).format(context), + ), + title: Text( + event.message, + style: + event.isError + ? TextStyle( + color: Theme.of(context).colorScheme.error, + ) + : null, + ), + ); + }, + ), + ), + ], + ), + ); + } +} + +class _StatsCard extends StatelessWidget { + const _StatsCard({ + required this.isRunning, + required this.startedAt, + required this.fixCount, + required this.savedCount, + required this.saveErrorCount, + required this.lastFix, + }); + + final bool isRunning; + final DateTime? startedAt; + final int fixCount; + final int savedCount; + final int saveErrorCount; + final GeoPosition? lastFix; + + @override + Widget build(BuildContext context) { + final elapsed = + startedAt == null ? null : DateTime.now().difference(startedAt!); + final lastFixTime = lastFix?.timestamp; + final lastFixAge = + lastFixTime == null ? null : DateTime.now().difference(lastFixTime); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + [ + if (isRunning) 'RUNNING' else 'STOPPED', + if (elapsed != null) 'elapsed: ${_format(elapsed)}', + 'fixes: $fixCount', + 'saved: $savedCount', + if (saveErrorCount > 0) 'save errors: $saveErrorCount', + if (lastFixAge != null) 'last fix: ${_format(lastFixAge)} ago', + ].join(' · '), + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + } + + String _format(Duration d) { + final minutes = d.inMinutes; + final seconds = d.inSeconds % 60; + return minutes > 0 ? '${minutes}m ${seconds}s' : '${seconds}s'; + } +} diff --git a/lib/modules/more/more_page.dart b/lib/modules/more/more_page.dart index 29d3a54b..d1fc488b 100644 --- a/lib/modules/more/more_page.dart +++ b/lib/modules/more/more_page.dart @@ -1,8 +1,10 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:thingsboard_app/config/routes/v2/routes_config/routes/location_tracking_routes.dart'; import 'package:thingsboard_app/config/themes/app_colors.dart'; import 'package:thingsboard_app/core/auth/login/provider/login_provider.dart'; import 'package:thingsboard_app/core/usecases/user_details_usecase.dart'; @@ -45,40 +47,53 @@ class MorePage extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ ProfileWidget(userDetails: userDetails, user: login.user), - if(items.isNotEmpty) - ...[Divider( - color: Colors.black.withValues(alpha: .05), - thickness: 1, - height: 0, - ), - Flexible( - child: SingleChildScrollView( - child: Column( - children: - items - .map( - (e) => MoreMenuItemWidget( - TbMainNavigationItem( - title: NavigationHelper.getLocalizedTitle( - context, - e.id, - e.path, - e.title, + if (items.isNotEmpty) ...[ + Divider( + color: Colors.black.withValues(alpha: .05), + thickness: 1, + height: 0, + ), + Flexible( + child: SingleChildScrollView( + child: Column( + children: + items + .map( + (e) => MoreMenuItemWidget( + TbMainNavigationItem( + title: NavigationHelper.getLocalizedTitle( + context, + e.id, + e.path, + e.title, + ), + icon: e.icon, + path: e.path, + showAdditionalIcon: + e.showNotificationBadge, ), - icon: e.icon, - path: e.path, - showAdditionalIcon: e.showNotificationBadge, + onTap: () { + context.push(e.path); + }, ), - onTap: () { - context.push(e.path); - }, - ), - ) - .toList(), + ) + .toList(), + ), ), ), - ), - ], + ], + // Debug-only entry to the phase 1a GPS tracking spike page. + if (kDebugMode) + MoreMenuItemWidget( + const TbMainNavigationItem( + title: 'GPS tracking spike', + icon: Icons.gps_fixed, + path: LocationTrackingRoutes.liveTrackingSpike, + ), + onTap: () { + context.push(LocationTrackingRoutes.liveTrackingSpike); + }, + ), Divider( color: Colors.black.withValues(alpha: .05), thickness: 1, @@ -92,8 +107,8 @@ class MorePage extends HookConsumerWidget { ), showTrailing: false, color: AppColors.textError, - onTap: () async { - await ref.read(loginProvider.notifier).logout(); + onTap: () async { + await ref.read(loginProvider.notifier).logout(); }, ), ], diff --git a/lib/utils/services/location/i_location_service.dart b/lib/utils/services/location/i_location_service.dart index f6d1b291..42d92c81 100644 --- a/lib/utils/services/location/i_location_service.dart +++ b/lib/utils/services/location/i_location_service.dart @@ -1,4 +1,5 @@ import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_stream_settings.dart'; /// Single entry point for GPS access across the app. Implementations own all /// permission / service-enabled handling so callers never touch the geolocator @@ -8,11 +9,15 @@ abstract interface class ILocationService { /// service-enabled checks internally. Future getCurrentPosition(); - /// Foreground live position updates. Pre-checks availability, then relays - /// each update as a [LocationSuccess]. Emits a terminal failure [LocationFix] - /// (and stops) if location is unavailable. Subscribers must cancel their - /// [StreamSubscription] when done (Riverpod/Bloc disposal handles this). - Stream positionStream({double distanceFilterMeters = 0}); + /// Live position updates. Pre-checks availability, then relays each update + /// as a [LocationSuccess]. Emits a terminal failure [LocationFix] (and + /// stops) if location is unavailable. Runs in background only when + /// [LocationStreamSettings.background] is set. Subscribers must cancel + /// their [StreamSubscription] when done (Riverpod/Bloc disposal handles + /// this). + Stream positionStream({ + LocationStreamSettings settings = const LocationStreamSettings(), + }); /// Opens the OS location settings screen. Returns true if it was opened. Future openLocationSettings(); diff --git a/lib/utils/services/location/location_service.dart b/lib/utils/services/location/location_service.dart index 04fef0b2..2f32670e 100644 --- a/lib/utils/services/location/location_service.dart +++ b/lib/utils/services/location/location_service.dart @@ -1,10 +1,12 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:geolocator/geolocator.dart'; import 'package:thingsboard_app/core/logger/tb_logger.dart'; import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_stream_settings.dart'; class LocationService implements ILocationService { LocationService({required TbLogger logger, GeolocatorPlatform? geolocator}) @@ -35,7 +37,9 @@ class LocationService implements ILocationService { } @override - Stream positionStream({double distanceFilterMeters = 0}) async* { + Stream positionStream({ + LocationStreamSettings settings = const LocationStreamSettings(), + }) async* { final unavailable = await _ensureAvailable(); if (unavailable != null) { yield unavailable; @@ -43,10 +47,7 @@ class LocationService implements ILocationService { } final raw = _geolocator.getPositionStream( - locationSettings: LocationSettings( - accuracy: LocationAccuracy.high, - distanceFilter: distanceFilterMeters.round(), - ), + locationSettings: _toLocationSettings(settings), ); yield* raw.transform( @@ -89,6 +90,42 @@ class LocationService implements ILocationService { return null; } + LocationSettings _toLocationSettings(LocationStreamSettings settings) { + final accuracy = switch (settings.accuracy) { + LocationAccuracyLevel.low => LocationAccuracy.low, + LocationAccuracyLevel.balanced => LocationAccuracy.medium, + LocationAccuracyLevel.high => LocationAccuracy.high, + }; + final background = settings.background; + + return switch (defaultTargetPlatform) { + TargetPlatform.android => AndroidSettings( + accuracy: accuracy, + distanceFilter: settings.distanceFilterMeters, + intervalDuration: settings.interval, + foregroundNotificationConfig: + background == null + ? null + : ForegroundNotificationConfig( + notificationTitle: background.notificationTitle, + notificationText: background.notificationText, + enableWakeLock: true, + setOngoing: true, + ), + ), + TargetPlatform.iOS => AppleSettings( + accuracy: accuracy, + distanceFilter: settings.distanceFilterMeters, + allowBackgroundLocationUpdates: background != null, + showBackgroundLocationIndicator: true, + ), + _ => LocationSettings( + accuracy: accuracy, + distanceFilter: settings.distanceFilterMeters, + ), + }; + } + GeoPosition _toGeoPosition(Position p) => GeoPosition( latitude: p.latitude, longitude: p.longitude, diff --git a/lib/utils/services/location/model/location_stream_settings.dart b/lib/utils/services/location/model/location_stream_settings.dart new file mode 100644 index 00000000..9f8166b3 --- /dev/null +++ b/lib/utils/services/location/model/location_stream_settings.dart @@ -0,0 +1,36 @@ +/// App-level accuracy tiers, decoupled from the geolocator plugin's enum the +/// same way [GeoPosition] is decoupled from its `Position`. +enum LocationAccuracyLevel { low, balanced, high } + +/// Enables tracking to continue while the app is backgrounded. On Android the +/// strings feed the mandatory foreground-service notification; iOS ignores +/// them (it shows the system location indicator instead). +class BackgroundTrackingConfig { + const BackgroundTrackingConfig({ + required this.notificationTitle, + required this.notificationText, + }); + + final String notificationTitle; + final String notificationText; +} + +class LocationStreamSettings { + const LocationStreamSettings({ + this.accuracy = LocationAccuracyLevel.high, + this.distanceFilterMeters = 0, + this.interval, + this.background, + }); + + final LocationAccuracyLevel accuracy; + + /// Minimum displacement between fixes; 0 reports every fix. + final int distanceFilterMeters; + + /// Desired time between fixes. Android-only hint; iOS paces by distance. + final Duration? interval; + + /// Non-null keeps the stream alive in background; null is foreground-only. + final BackgroundTrackingConfig? background; +} From dd8f88aadc293f864ef8b0971314d3d011a0a0d7 Mon Sep 17 00:00:00 2001 From: ababak Date: Fri, 3 Jul 2026 14:35:31 +0300 Subject: [PATCH 05/40] docs: add GPS tracking design spec and location service design docs --- .../plans/2026-06-01-location-service.md | 679 ++++++++++++++++++ .../2026-06-01-location-service-design.md | 215 ++++++ .../specs/2026-07-03-gps-tracking-design.md | 194 +++++ 3 files changed, 1088 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-01-location-service.md create mode 100644 docs/superpowers/specs/2026-06-01-location-service-design.md create mode 100644 docs/superpowers/specs/2026-07-03-gps-tracking-design.md diff --git a/docs/superpowers/plans/2026-06-01-location-service.md b/docs/superpowers/plans/2026-06-01-location-service.md new file mode 100644 index 00000000..881d4ebf --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-location-service.md @@ -0,0 +1,679 @@ +# Location Service Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a reusable, fully-tested `ILocationService` that owns all GPS access behind one interface, and refactor the existing `GetLocationAction` to delegate to it. + +**Architecture:** A single cross-cutting GetIt lazy-singleton service (like `IFirebaseService`). It wraps geolocator's injectable `GeolocatorPlatform` (so it is unit-testable with no device), supports a one-shot fix and a foreground live stream, and reports outcomes via an exhaustive sealed `LocationFix` type. Background tracking and UI are explicitly out of scope. + +**Tech Stack:** Dart 3 sealed classes + exhaustive `switch`, freezed 3.x (for the `GeoPosition` data model), `geolocator: ^12.0.0` (`GeolocatorPlatform`), GetIt, mocktail + flutter_test. + +**Spec:** `docs/superpowers/specs/2026-06-01-location-service-design.md` + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `lib/utils/services/location/model/geo_position.dart` | Plugin-agnostic immutable position (freezed). Keeps geolocator's `Position` out of callers. | +| `lib/utils/services/location/model/location_fix.dart` | Sealed result union — `LocationSuccess` / `LocationServicesDisabled` / `LocationPermissionDenied` / `LocationPermissionDeniedForever` / `LocationFixError`. | +| `lib/utils/services/location/i_location_service.dart` | The interface callers depend on. | +| `lib/utils/services/location/location_service.dart` | Implementation; the ONLY file importing geolocator. | +| `lib/locator.dart` | Register `ILocationService` as a lazy singleton (modify). | +| `lib/utils/services/mobile_actions/actions/get_location_action.dart` | Refactor to delegate to `ILocationService` (modify). | +| `test/utils/services/location/location_service_test.dart` | Unit tests with a mocked `GeolocatorPlatform`. | + +**Convention notes (verified against the codebase):** +- `LocationFix` is a **plain Dart `sealed class`** (matching the project's state unions like `lib/modules/alarm/presentation/bloc/alarm_types/alarm_types_state.dart`), NOT a freezed union. Dart 3's exhaustive `switch` gives the compiler-enforced handling the spec wants, with no codegen. The spec sketched this as "freezed sealed"; a plain sealed class is the idiomatic equivalent here. +- `GeoPosition` IS freezed `abstract class` (matching `lib/utils/services/device_profile/model/cached_device_profile.dart`). +- `getIt` is defined in `lib/locator.dart:34`; `TbLogger` is registered at `lib/locator.dart:45`, so `getIt()` resolves it. +- `GeolocatorPlatform` is exported by `package:geolocator/geolocator.dart`. Its instance methods take `locationSettings:` (NOT the static `desiredAccuracy:`). + +**Intentional behavior change:** the current `GetLocationAction._checkService()` auto-opens the OS location settings and re-checks in a loop when services are off. The refactor drops that loop — the service simply reports `LocationServicesDisabled`, and the action returns the same `'Location services are disabled.'` error string it returns today. This matches the spec's "caller decides" model and removes a hidden blocking loop. The error strings returned to the dashboard WebView are preserved exactly. + +--- + +## Task 1: `GeoPosition` model (freezed) + +**Files:** +- Create: `lib/utils/services/location/model/geo_position.dart` +- Create: `test/utils/services/location/geo_position_test.dart` +- Generated: `lib/utils/services/location/model/geo_position.freezed.dart` (via build_runner) + +- [ ] **Step 1: Write the failing test** + +Create `test/utils/services/location/geo_position_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; + +void main() { + test('GeoPosition value equality holds for identical fields', () { + final ts = DateTime.fromMillisecondsSinceEpoch(1000); + final a = GeoPosition( + latitude: 1.5, + longitude: 2.5, + accuracy: 3.0, + timestamp: ts, + ); + final b = GeoPosition( + latitude: 1.5, + longitude: 2.5, + accuracy: 3.0, + timestamp: ts, + ); + + expect(a, equals(b)); + expect(a.latitude, 1.5); + expect(a.longitude, 2.5); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/utils/services/location/geo_position_test.dart` +Expected: FAIL — `Target of URI doesn't exist` / `GeoPosition` undefined (file not created yet). + +- [ ] **Step 3: Create the model** + +Create `lib/utils/services/location/model/geo_position.dart`: + +```dart +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'geo_position.freezed.dart'; + +/// Plugin-agnostic GPS position. Keeps `package:geolocator`'s `Position` +/// type from leaking past the location service boundary. +@freezed +abstract class GeoPosition with _$GeoPosition { + const factory GeoPosition({ + required double latitude, + required double longitude, + required double accuracy, + DateTime? timestamp, + }) = _GeoPosition; +} +``` + +- [ ] **Step 4: Run code generation** + +Run: `flutter pub run build_runner build --delete-conflicting-outputs` +Expected: generates `geo_position.freezed.dart`, exits 0. + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `flutter test test/utils/services/location/geo_position_test.dart` +Expected: PASS. + +- [ ] **Step 6: Format & commit** + +```bash +dart format lib/utils/services/location/model/geo_position.dart test/utils/services/location/geo_position_test.dart +git add lib/utils/services/location/model/geo_position.dart lib/utils/services/location/model/geo_position.freezed.dart test/utils/services/location/geo_position_test.dart +git commit -m "feat(location): add GeoPosition model" +``` + +--- + +## Task 2: `LocationFix` sealed result + +**Files:** +- Create: `lib/utils/services/location/model/location_fix.dart` +- Create: `test/utils/services/location/location_fix_test.dart` + +- [ ] **Step 1: Write the failing test** + +Create `test/utils/services/location/location_fix_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; + +String describe(LocationFix fix) => switch (fix) { + LocationSuccess(:final position) => 'ok:${position.latitude}', + LocationServicesDisabled() => 'services-off', + LocationPermissionDenied() => 'denied', + LocationPermissionDeniedForever() => 'denied-forever', + LocationFixError(:final message) => 'error:$message', + }; + +void main() { + test('every LocationFix variant is matched exhaustively', () { + expect( + describe( + const LocationSuccess( + GeoPosition(latitude: 10, longitude: 20, accuracy: 1), + ), + ), + 'ok:10.0', + ); + expect(describe(const LocationServicesDisabled()), 'services-off'); + expect(describe(const LocationPermissionDenied()), 'denied'); + expect(describe(const LocationPermissionDeniedForever()), 'denied-forever'); + expect(describe(const LocationFixError('boom')), 'error:boom'); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/utils/services/location/location_fix_test.dart` +Expected: FAIL — `LocationFix` and variants undefined. + +- [ ] **Step 3: Create the sealed class** + +Create `lib/utils/services/location/model/location_fix.dart`: + +```dart +import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; + +/// Outcome of a location request. Exhaustively matched with `switch`, so every +/// caller is forced by the compiler to handle each failure mode. +sealed class LocationFix { + const LocationFix(); +} + +/// A position was obtained. +final class LocationSuccess extends LocationFix { + const LocationSuccess(this.position); + + final GeoPosition position; +} + +/// The OS location services are turned off. Caller may prompt the user and +/// call [ILocationService.openLocationSettings]. +final class LocationServicesDisabled extends LocationFix { + const LocationServicesDisabled(); +} + +/// Permission was denied but can be requested again later. +final class LocationPermissionDenied extends LocationFix { + const LocationPermissionDenied(); +} + +/// Permission was permanently denied. Caller must deep-link via +/// [ILocationService.openAppSettings]. +final class LocationPermissionDeniedForever extends LocationFix { + const LocationPermissionDeniedForever(); +} + +/// An unexpected platform error occurred. +final class LocationFixError extends LocationFix { + const LocationFixError(this.message); + + final String message; +} +``` + +> Note: the doc-comment references to `ILocationService` resolve once Task 3 lands; they are comments only and do not affect compilation. + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/utils/services/location/location_fix_test.dart` +Expected: PASS. + +- [ ] **Step 5: Format & commit** + +```bash +dart format lib/utils/services/location/model/location_fix.dart test/utils/services/location/location_fix_test.dart +git add lib/utils/services/location/model/location_fix.dart test/utils/services/location/location_fix_test.dart +git commit -m "feat(location): add LocationFix sealed result" +``` + +--- + +## Task 3: `ILocationService` interface + +**Files:** +- Create: `lib/utils/services/location/i_location_service.dart` + +(No test — interface declaration only; it is exercised through Task 4.) + +- [ ] **Step 1: Create the interface** + +Create `lib/utils/services/location/i_location_service.dart`: + +```dart +import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; + +/// Single entry point for GPS access across the app. Implementations own all +/// permission / service-enabled handling so callers never touch the geolocator +/// plugin directly. +abstract interface class ILocationService { + /// Resolves a single current position, performing the full permission and + /// service-enabled checks internally. + Future getCurrentPosition(); + + /// Foreground live position updates. Pre-checks availability, then relays + /// each update as a [LocationSuccess]. Emits a terminal failure [LocationFix] + /// (and stops) if location is unavailable. Subscribers must cancel their + /// [StreamSubscription] when done (Riverpod/Bloc disposal handles this). + Stream positionStream({double distanceFilterMeters = 0}); + + /// Opens the OS location settings screen. Returns true if it was opened. + Future openLocationSettings(); + + /// Opens this app's settings screen (for permanently-denied permission). + Future openAppSettings(); +} +``` + +- [ ] **Step 2: Verify it analyzes clean** + +Run: `flutter analyze lib/utils/services/location/i_location_service.dart` +Expected: No issues. + +- [ ] **Step 3: Format & commit** + +```bash +dart format lib/utils/services/location/i_location_service.dart +git add lib/utils/services/location/i_location_service.dart +git commit -m "feat(location): add ILocationService interface" +``` + +--- + +## Task 4: `LocationService` implementation (TDD, the core) + +**Files:** +- Create: `lib/utils/services/location/location_service.dart` +- Create: `test/utils/services/location/location_service_test.dart` + +- [ ] **Step 1: Write the failing test** + +Create `test/utils/services/location/location_service_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/utils/services/location/location_service.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; + +class MockGeolocatorPlatform extends Mock implements GeolocatorPlatform {} + +Position _fakePosition() => Position( + latitude: 12.34, + longitude: 56.78, + timestamp: DateTime.fromMillisecondsSinceEpoch(0), + accuracy: 5, + altitude: 0, + altitudeAccuracy: 0, + heading: 0, + headingAccuracy: 0, + speed: 0, + speedAccuracy: 0, + ); + +void main() { + late MockGeolocatorPlatform geo; + late LocationService service; + + setUpAll(() { + registerFallbackValue(const LocationSettings()); + }); + + setUp(() { + geo = MockGeolocatorPlatform(); + service = LocationService(logger: TbLogger(), geolocator: geo); + }); + + test('returns LocationServicesDisabled when services are off', () async { + when(() => geo.isLocationServiceEnabled()).thenAnswer((_) async => false); + + final fix = await service.getCurrentPosition(); + + expect(fix, isA()); + verifyNever(() => geo.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )); + }); + + test('returns LocationPermissionDenied when request stays denied', () async { + when(() => geo.isLocationServiceEnabled()).thenAnswer((_) async => true); + when(() => geo.checkPermission()) + .thenAnswer((_) async => LocationPermission.denied); + when(() => geo.requestPermission()) + .thenAnswer((_) async => LocationPermission.denied); + + final fix = await service.getCurrentPosition(); + + expect(fix, isA()); + }); + + test('returns LocationPermissionDeniedForever', () async { + when(() => geo.isLocationServiceEnabled()).thenAnswer((_) async => true); + when(() => geo.checkPermission()) + .thenAnswer((_) async => LocationPermission.deniedForever); + + final fix = await service.getCurrentPosition(); + + expect(fix, isA()); + }); + + test('returns LocationSuccess with mapped GeoPosition', () async { + when(() => geo.isLocationServiceEnabled()).thenAnswer((_) async => true); + when(() => geo.checkPermission()) + .thenAnswer((_) async => LocationPermission.whileInUse); + when(() => geo.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => _fakePosition()); + + final fix = await service.getCurrentPosition(); + + expect(fix, isA()); + final pos = (fix as LocationSuccess).position; + expect(pos.latitude, 12.34); + expect(pos.longitude, 56.78); + expect(pos.accuracy, 5); + }); + + test('re-requests permission when initially denied, then succeeds', + () async { + when(() => geo.isLocationServiceEnabled()).thenAnswer((_) async => true); + when(() => geo.checkPermission()) + .thenAnswer((_) async => LocationPermission.denied); + when(() => geo.requestPermission()) + .thenAnswer((_) async => LocationPermission.whileInUse); + when(() => geo.getCurrentPosition( + locationSettings: any(named: 'locationSettings'), + )).thenAnswer((_) async => _fakePosition()); + + final fix = await service.getCurrentPosition(); + + expect(fix, isA()); + verify(() => geo.requestPermission()).called(1); + }); + + test('maps thrown errors to LocationFixError', () async { + when(() => geo.isLocationServiceEnabled()) + .thenThrow(Exception('platform boom')); + + final fix = await service.getCurrentPosition(); + + expect(fix, isA()); + expect((fix as LocationFixError).message, contains('boom')); + }); +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flutter test test/utils/services/location/location_service_test.dart` +Expected: FAIL — `LocationService` undefined. + +- [ ] **Step 3: Implement the service** + +Create `lib/utils/services/location/location_service.dart`: + +```dart +import 'dart:async'; + +import 'package:geolocator/geolocator.dart'; +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; +import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; + +class LocationService implements ILocationService { + LocationService({ + required TbLogger logger, + GeolocatorPlatform? geolocator, + }) : _log = logger, + _geolocator = geolocator ?? GeolocatorPlatform.instance; + + final TbLogger _log; + final GeolocatorPlatform _geolocator; + + @override + Future getCurrentPosition() async { + try { + final unavailable = await _ensureAvailable(); + if (unavailable != null) { + return unavailable; + } + + final position = await _geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + return LocationSuccess(_toGeoPosition(position)); + } catch (e, s) { + _log.error('LocationService.getCurrentPosition failed', e, s); + return LocationFixError(e.toString()); + } + } + + @override + Stream positionStream({double distanceFilterMeters = 0}) async* { + final unavailable = await _ensureAvailable(); + if (unavailable != null) { + yield unavailable; + return; + } + + final raw = _geolocator.getPositionStream( + locationSettings: LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: distanceFilterMeters.round(), + ), + ); + + yield* raw.transform( + StreamTransformer.fromHandlers( + handleData: (position, sink) => + sink.add(LocationSuccess(_toGeoPosition(position))), + handleError: (e, s, sink) { + _log.error('LocationService.positionStream error', e, s); + sink.add(LocationFixError(e.toString())); + }, + ), + ); + } + + @override + Future openLocationSettings() => _geolocator.openLocationSettings(); + + @override + Future openAppSettings() => _geolocator.openAppSettings(); + + /// Returns `null` when location is available, otherwise a failure + /// [LocationFix] describing why it is not. + Future _ensureAvailable() async { + final serviceEnabled = await _geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + return const LocationServicesDisabled(); + } + + var permission = await _geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await _geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + return const LocationPermissionDenied(); + } + } + if (permission == LocationPermission.deniedForever) { + return const LocationPermissionDeniedForever(); + } + return null; + } + + GeoPosition _toGeoPosition(Position p) => GeoPosition( + latitude: p.latitude, + longitude: p.longitude, + accuracy: p.accuracy, + timestamp: p.timestamp, + ); +} +``` + +> If `flutter analyze` flags the `TbLogger.error` signature, check `lib/core/logger/tb_logger.dart` for the exact method name/arity and adjust the two `_log.error(...)` calls to match (the project's logger API is the source of truth). + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flutter test test/utils/services/location/location_service_test.dart` +Expected: PASS (all 6 tests). + +- [ ] **Step 5: Format & commit** + +```bash +dart format lib/utils/services/location/location_service.dart test/utils/services/location/location_service_test.dart +git add lib/utils/services/location/location_service.dart test/utils/services/location/location_service_test.dart +git commit -m "feat(location): add LocationService backed by GeolocatorPlatform" +``` + +--- + +## Task 5: Register `ILocationService` in the locator + +**Files:** +- Modify: `lib/locator.dart` + +- [ ] **Step 1: Add the import** + +At the top of `lib/locator.dart`, with the other imports, add: + +```dart +import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; +import 'package:thingsboard_app/utils/services/location/location_service.dart'; +``` + +- [ ] **Step 2: Add the registration** + +In `setUpRootDependencies()`, inside the `getIt` cascade, add this line next to the other `registerLazySingleton` utility-service entries (e.g. right after the `ITbImageGalleryService` registration around `lib/locator.dart:54`): + +```dart + ..registerLazySingleton( + () => LocationService(logger: getIt()), + ) +``` + +- [ ] **Step 3: Verify it analyzes and resolves** + +Run: `flutter analyze lib/locator.dart` +Expected: No issues. (`getIt()` resolves `TbLogger`, registered at `lib/locator.dart:45`.) + +- [ ] **Step 4: Format & commit** + +```bash +dart format lib/locator.dart +git add lib/locator.dart +git commit -m "feat(location): register ILocationService in the locator" +``` + +--- + +## Task 6: Refactor `GetLocationAction` to delegate + +**Files:** +- Modify: `lib/utils/services/mobile_actions/actions/get_location_action.dart` + +- [ ] **Step 1: Replace the file contents** + +Overwrite `lib/utils/services/mobile_actions/actions/get_location_action.dart` with: + +```dart +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action_result.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_result.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_type.dart'; + +class GetLocationAction extends MobileAction { + @override + Future execute( + List args, + InAppWebViewController controller, + ) async { + try { + final fix = await getIt().getCurrentPosition(); + return switch (fix) { + LocationSuccess(:final position) => + WidgetMobileActionResult.successResult( + MobileActionResult.location( + position.latitude, + position.longitude, + ), + ), + LocationServicesDisabled() => WidgetMobileActionResult.errorResult( + 'Location services are disabled.', + ), + LocationPermissionDenied() => WidgetMobileActionResult.errorResult( + 'Location permissions are denied.', + ), + LocationPermissionDeniedForever() => + WidgetMobileActionResult.errorResult( + 'Location permissions are permanently denied, we cannot request permissions.', + ), + LocationFixError(:final message) => + WidgetMobileActionResult.errorResult(message), + }; + } catch (e) { + return handleError(e); + } + } + + @override + WidgetMobileActionType get type => WidgetMobileActionType.getLocation; +} +``` + +This preserves the exact error strings the dashboard WebView receives today, and `WidgetActionHandler`'s static `actions` list still constructs `GetLocationAction()` with no arguments — unchanged. + +- [ ] **Step 2: Verify it analyzes clean** + +Run: `flutter analyze lib/utils/services/mobile_actions/actions/get_location_action.dart` +Expected: No issues. (Confirm `geolocator` is no longer imported here — it should now live only in `location_service.dart`.) + +- [ ] **Step 3: Confirm geolocator is contained** + +Run: `grep -rl "package:geolocator" lib` +Expected: only `lib/utils/services/location/location_service.dart`. + +- [ ] **Step 4: Format & commit** + +```bash +dart format lib/utils/services/mobile_actions/actions/get_location_action.dart +git add lib/utils/services/mobile_actions/actions/get_location_action.dart +git commit -m "refactor(location): make GetLocationAction delegate to ILocationService" +``` + +--- + +## Task 7: Full verification + +**Files:** none (verification only) + +- [ ] **Step 1: Run the full location test suite** + +Run: `flutter test test/utils/services/location/` +Expected: all tests PASS. + +- [ ] **Step 2: Analyze the whole project** + +Run: `flutter analyze` +Expected: No new issues introduced by these files. + +- [ ] **Step 3: Confirm formatting is clean** + +Run: `dart format --output=none --set-exit-if-changed lib/utils/services/location test/utils/services/location lib/utils/services/mobile_actions/actions/get_location_action.dart` +Expected: exit 0 (nothing to reformat). + +--- + +## Self-Review (completed by plan author) + +- **Spec coverage:** interface (Task 3) covers on-demand + stream + settings escape hatches; sealed result (Task 2) covers all five failure/success states; placement & registration (Tasks 1–5) match the cross-cutting-singleton design; refactor (Task 6) makes the service the single source of truth; testability via injected `GeolocatorPlatform` (Task 4) is implemented. Background tracking / UI / Riverpod wrapper / last-known caching correctly absent (non-goals). +- **Placeholder scan:** no TBD/TODO; every code step shows full code; every command has expected output. The one conditional note (TbLogger.error signature) points at a concrete source file to check. +- **Type consistency:** `LocationFix` variant names (`LocationSuccess`, `LocationServicesDisabled`, `LocationPermissionDenied`, `LocationPermissionDeniedForever`, `LocationFixError`) are identical across Tasks 2, 4, and 6. `GeoPosition` fields (`latitude`, `longitude`, `accuracy`, `timestamp`) are consistent across Tasks 1, 4, and the tests. `ILocationService` method names match between Tasks 3, 4, and 6. +``` diff --git a/docs/superpowers/specs/2026-06-01-location-service-design.md b/docs/superpowers/specs/2026-06-01-location-service-design.md new file mode 100644 index 00000000..8577e003 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-location-service-design.md @@ -0,0 +1,215 @@ +# Design: Reusable Location Service + +**Date:** 2026-06-01 +**Status:** Approved — ready for implementation planning +**Author:** ababak (with Claude) + +## Background / Motivation + +There is no concrete location feature requested yet. The goal of this work is +**architectural**: establish a clean, reusable abstraction for accessing the +phone's GPS so future features (device geo-tagging, "track me on a map" screens, +geofence checks, etc.) can build on a single, well-tested foundation instead of +re-implementing the fiddly permission/service-enabled handling each time. + +### What already exists + +Location access is **not** greenfield — but it is trapped in one place: + +- `geolocator: ^12.0.0` (+ `geolocator_android`) and `permission_handler: ^11.3.1` + are already in `pubspec.yaml`. +- A single one-shot consumer exists: `GetLocationAction` + (`lib/utils/services/mobile_actions/actions/get_location_action.dart`). It is + the **only** place `geolocator` is called in the entire codebase. +- It is triggered exclusively by the ThingsBoard dashboard WebView: a dashboard + widget calls JS `tbMobileHandler(['getLocation', ...])` → + `WidgetActionHandler.handleWidgetMobileAction` → `GetLocationAction.execute()` + → returns lat/lng JSON back into the dashboard. It is a sibling of + `takePhoto`, `scanQrCode`, `makePhoneCall`, etc. +- Platform permissions are **already declared**: Android + `ACCESS_FINE_LOCATION` (maxSdk 35) + `ACCESS_COARSE_LOCATION`; iOS + `NSLocation*UsageDescription` strings. + +### The gap + +The permission / service-enabled / `denied` vs `deniedForever` logic lives +inline inside `GetLocationAction`. There is no shared service, so any future +location consumer would have to duplicate that logic, and there is no streaming +capability for live-update screens. + +## Goals + +- A single cross-cutting `ILocationService` that owns all `geolocator` usage. +- Support **on-demand** position and a **foreground live stream** from day one + (the stream is a cheap extension riding on the same permission code). +- A typed, exhaustive result model so callers must handle every failure state + with the appropriate UX response. +- Refactor the existing `GetLocationAction` to delegate to the service, making + the service immediately exercised by a real caller (not speculative) and + giving a single source of truth for the permission dance. +- Be fully unit-testable without a device. + +## Non-goals (YAGNI) + +- **Background / while-closed tracking** — no use case; high cost (Android + foreground service + `ACCESS_BACKGROUND_LOCATION`, iOS background mode + App + Store review). The interface is shaped so this can be added later without a + breaking change. +- **Maps or any UI** — none needed yet. +- **A Riverpod provider wrapper** — deferred until a reactive screen needs it; + trivial to add a `@riverpod` wrapper over the GetIt singleton at that point. +- **Last-known-position caching** — add only when a caller wants it. + +## Architecture & placement + +A single cross-cutting service registered globally in GetIt, exactly like +`IFirebaseService` / `IEndpointService`. No feature module and no `presentation/` +layer (there is no UI use case). All `geolocator` usage is contained behind this +one interface — callers never import the plugin. + +``` +lib/utils/services/location/ +├── i_location_service.dart # interface +├── location_service.dart # implementation (only file that imports geolocator) +└── model/ + ├── location_fix.dart # sealed result (freezed) + └── geo_position.dart # plugin-agnostic position model (freezed) +``` + +Registration in `lib/locator.dart`: + +```dart +..registerLazySingleton( + () => LocationService(logger: getIt()), +) +``` + +## Interface + +```dart +abstract interface class ILocationService { + /// One-shot fix. Handles the full permission/service-enabled dance internally. + Future getCurrentPosition(); + + /// Foreground live updates. Pre-checks availability, then relays updates. + /// Subscribers cancel via StreamSubscription (Riverpod/Bloc disposal handles this). + Stream positionStream({double distanceFilterMeters = 0}); + + /// Escape hatches for the UI to resolve failure states. + Future openLocationSettings(); // for ServicesDisabled + Future openAppSettings(); // for PermissionDeniedForever +} +``` + +## Data model — sealed result (freezed) + +Naming: there is already a `LocationResult` (the WebView JSON DTO in +`mobile_actions/results/`). To avoid collision, the new sealed type is +**`LocationFix`**. + +```dart +@freezed +sealed class LocationFix with _$LocationFix { + const factory LocationFix.success(GeoPosition position) = LocationSuccess; + const factory LocationFix.servicesDisabled() = LocationServicesDisabled; + const factory LocationFix.permissionDenied() = LocationPermissionDenied; + const factory LocationFix.permissionDeniedForever() = LocationPermissionDeniedForever; + const factory LocationFix.error(String message) = LocationFixError; +} + +@freezed +class GeoPosition with _$GeoPosition { + const factory GeoPosition({ + required double latitude, + required double longitude, + required double accuracy, + DateTime? timestamp, + }) = _GeoPosition; +} +``` + +Callers `switch` over `LocationFix`; Dart 3 exhaustive pattern matching forces +handling of every failure state. `GeoPosition` is our own model so geolocator's +`Position` type never leaks past the service boundary. + +Each failure state maps to a distinct caller UX: + +| State | Meaning | Typical caller response | +|---|---|---| +| `LocationSuccess` | got a fix | use `position` | +| `LocationServicesDisabled` | OS location is off | prompt + `openLocationSettings()` | +| `LocationPermissionDenied` | app permission denied (re-askable) | inform / re-request later | +| `LocationPermissionDeniedForever` | denied permanently | deep-link via `openAppSettings()` | +| `LocationFixError` | unexpected platform error | show message, log | + +## Data flow + +### On-demand (the refactor) + +`GetLocationAction` shrinks to a thin mapper; the permission logic now lives in +the service. The WebView bridge above it (`tbMobileHandler` → +`WidgetActionHandler` → `execute`) is untouched. + +```dart +final fix = await getIt().getCurrentPosition(); +return switch (fix) { + LocationSuccess(:final position) => + WidgetMobileActionResult.success( + LocationResult(position.latitude, position.longitude), + ), + LocationServicesDisabled() => /* openLocationSettings + error string */, + LocationPermissionDenied() => /* error string */, + LocationPermissionDeniedForever() => /* error string */, + LocationFixError(:final message) => /* error string */, +}; +``` + +### Stream + +`positionStream` does an availability pre-check; on failure it emits a single +terminal `LocationFix` (e.g. `permissionDenied`) then closes; on success it +relays each geolocator position update as `LocationFix.success(...)`. This keeps +the result model consistent between the one-shot and streaming paths, and lets +stream subscribers observe a mid-stream permission/service loss. + +## Testability + +`geolocator`'s public API is static (`Geolocator.getCurrentPosition()`), which +is normally unmockable. The plugin exposes an injectable +`GeolocatorPlatform.instance`, so the service takes it as an optional dependency: + +```dart +LocationService({required TbLogger logger, GeolocatorPlatform? geolocator}) + : _geolocator = geolocator ?? GeolocatorPlatform.instance; +``` + +Tests inject a mock `GeolocatorPlatform` (mocktail) and assert each branch maps +to the correct `LocationFix`: + +- location services off → `LocationServicesDisabled` +- `LocationPermission.denied` → `LocationPermissionDenied` +- `LocationPermission.deniedForever` → `LocationPermissionDeniedForever` +- valid `Position` → `LocationSuccess` with mapped `GeoPosition` +- thrown exception → `LocationFixError` + +Fully unit-testable with no device. + +## Implementation outline + +1. Add `geolocator_platform_interface` usage is already transitively available + via `geolocator`; confirm `GeolocatorPlatform` import path. +2. Create `model/geo_position.dart` and `model/location_fix.dart` (freezed), + run `build_runner`. +3. Create `i_location_service.dart` and `location_service.dart` implementing the + permission/service-enabled flow and mapping to `LocationFix`. +4. Register `ILocationService` as a lazy singleton in `lib/locator.dart`. +5. Refactor `GetLocationAction` to delegate to `getIt()` and + map `LocationFix` to its existing `WidgetMobileActionResult` outputs. +6. Add unit tests for `LocationService` using a mocked `GeolocatorPlatform`. +7. `flutter analyze` + `dart format` the changed files. + +## Open questions + +None blocking. Accuracy level is fixed at `high` internally for now (matching +current `GetLocationAction` behavior); a parameter can be added later without a +breaking change. diff --git a/docs/superpowers/specs/2026-07-03-gps-tracking-design.md b/docs/superpowers/specs/2026-07-03-gps-tracking-design.md new file mode 100644 index 00000000..6dfaf050 --- /dev/null +++ b/docs/superpowers/specs/2026-07-03-gps-tracking-design.md @@ -0,0 +1,194 @@ +# GPS Tracking via Mobile Actions — Design + +**Date:** 2026-07-03 +**Status:** Approved (brainstorming session with Artem) +**Repos involved:** + +| Role | Working repo (CE, branch `feat/gps-tracker`) | Merge target (PE, branch `feat/gps-tracker`) | +|---|---|---| +| Platform UI (ui-ngx) | `/home/artem/projects/thingsboard` | `/home/artem/projects/thingsboard-pe` | +| Mobile app | `/home/artem/projects/mobile/flutter_thingsboard_app` | `/home/artem/projects/mobile/flutter_thingsboard_pe_app` | + +Workflow: implement in CE repos first, merge into PE when a PE-only capability +(e.g. solution templates) is needed for testing. No backend (Java) changes in v1. + +## Goal + +Make phone GPS a first-class, reusable data source for ThingsBoard solutions: + +1. **One-shot**: a dashboard mobile action that saves the phone's current + coordinates to a configurable target entity (attributes or telemetry) — + declaratively, no hand-edited JS, so solution templates can ship it. +2. **Live tracking**: start/stop continuous tracking from a dashboard action; + the mobile app streams positions to the target entity as telemetry, + **including while the app is backgrounded / screen locked** (hard v1 + requirement), with a persistent in-app status bar and session screen. + +## Division of labor (chosen approach: hybrid) + +- **ThingsBoard owns configuration.** All parameters live in the widget action + descriptor inside the dashboard JSON — reusable in solution templates, + no backend schema changes (dashboard JSON is opaque to the server). +- **The web runtime resolves the target entity at click time** (alias / + current user / attribute indirection → concrete `{entityType, id}`) and + hands the mobile app a fully-resolved session config via the existing + `tbMobileHandler(type, ...args)` bridge. +- **The mobile app owns the runtime**: geolocator stream, REST saves via + `thingsboard_client`, OS background execution, status bar, session screen. +- A separate in-app *configuration* page was rejected for v1 (not + template-distributable, duplicates dashboard tooling). The in-app session + screen is runtime UI only. + +## ThingsBoard side (ui-ngx) + +### One-shot: extend the existing `getLocation` action + +Add an optional declarative save block to `GetLocationDescriptor` +(`ui-ngx/src/app/shared/models/widget.models.ts`): + +- `saveToEntity?: boolean` (default false → today's behavior, fully + backward compatible; save happens web-side after the app returns the fix, + so old mobile apps keep working) +- `targetEntity?: TargetEntityConfig` (shared block, below) +- `saveAs?: 'attributes' | 'timeseries'` (default `attributes`, scope + `SERVER_SCOPE`) +- `latitudeKey` / `longitudeKey` (defaults `latitude` / `longitude`) +- `includeMetadata?: boolean` — also save `accuracy` and fix timestamp +- `processLocationFunction` stays available for custom post-processing + +Execution in `widget.component.ts` `handleMobileAction` (`getLocation` case): +after receiving the result, resolve target, save via +`widgetContext.attributeService.saveEntityAttributes` / +`saveEntityTimeseries`, then invoke `processLocationFunction` if present. + +### Live tracking: two new `WidgetMobileActionType` values + +`startLiveLocation` and `stopLiveLocation`. New types (not a mode on +`getLocation`) because: + +- old app + overloaded `getLocation` would silently do a one-shot while the + dashboard believes tracking started; an unknown type fails loudly via the + app's `UnknownAction` fallback; +- one type = one result contract / one editor form is the existing idiom + (`mapDirection` vs `mapLocation`); +- the action-type dropdown is the natural mode selector, and templates need a + standalone "Stop tracking" button anyway. + +`StartLiveLocationDescriptor`: + +- `targetEntity: TargetEntityConfig` +- `saveAs`: telemetry (default) + `mirrorToAttributes?: boolean` so + attribute-driven map widgets update live +- `latitudeKey` / `longitudeKey` + `includeMetadata` (accuracy, altitude, + speed, heading as extra keys) +- `accuracy`: `high` | `balanced` | `low` (maps to geolocator accuracy) +- refresh strategy: `distanceFilterMeters?: number`, + `intervalSeconds?: number`, or both (hybrid = whichever fires first) +- stop condition: manual only | `maxDurationMinutes` +- `writeStatusAttributes?: boolean` (system attributes, below) + +`stopLiveLocation` needs no config beyond the common handlers. + +Web side sends `tbMobileHandler('startLiveLocation', resolvedConfigJson)`; +the app replies immediately with a launch-style ack +(`{launched: true}` shape), not a location result. + +### Shared `TargetEntityConfig` + +- `CURRENT_ENTITY` — widget's clicked/active entity (default; today's + implicit behavior) +- `CURRENT_USER` — logged-in user entity +- `ENTITY_ALIAS` — alias picker, resolved via `widgetContext.aliasController` +- `FROM_ATTRIBUTE` — read an attribute key from current user or current + entity whose value identifies the target: either a plain UUID + explicit + entity-type dropdown in config, or a `{"entityType": ..., "id": ...}` JSON + value + +Always resolved web-side at click time to a concrete `{entityType, id}`. +Single target per session in v1 (config resolves to a list internally so +multi-target can be added later). + +## Mobile app side (Flutter) + +### Tracking session runtime + +- `LiveLocationTrackingService` (GetIt singleton) owning at most one active + session; state exposed as a Riverpod provider (project convention for + state the router-level UI reacts to). +- Consumes `ILocationService.positionStream()` (existing abstraction over + geolocator) with configured accuracy/filters; each fix → + `saveEntityTelemetry` with the device timestamp (enables later offline + backfill). +- Starting a new session while one is active prompts to replace it. +- New mobile actions: rename/repurpose the existing `getLiveLocation` + placeholder to `startLiveLocation`, add `stopLiveLocation`. + +### Background execution (v1, de-risked first — see Phase 1a) + +- Android: geolocator `AndroidSettings.foregroundNotificationConfig` + (plugin-managed foreground service + persistent notification); + manifest gets `FOREGROUND_SERVICE` + `FOREGROUND_SERVICE_LOCATION`. +- iOS: `UIBackgroundModes` += `location` (both Info-Debug and Info-Release + plists), `AppleSettings(allowBackgroundLocationUpdates: true, + showBackgroundLocationIndicator: true, pauseLocationUpdatesAutomatically: + false)`. While-in-use permission is sufficient when the session starts in + the foreground. +- Tracking survives backgrounding/screen-lock, not app kill (acceptable v1; + `gpsLastUpdateTime` staleness covers detection). + +### Status bar + session screen + +- Persistent tracking bar injected at the `ShellRoute` / + `RouteHanlderWidget` level so it shows on every page: "Live tracking + active · last update Xs ago" with **Stop**, **Pause/Resume**, **Hide** + (collapse to small pill; does not stop tracking). Tap → session screen. +- Session screen: new module `lib/modules/location_tracking/` + go_router + route. Shows target entity, fix count, last fix (coords/accuracy), elapsed + time, save errors (e.g. 403), and the same controls. + +## System attributes (written by the app on the target entity) + +- `gpsActive` (bool) — true on start, false on clean stop; means "user + intends to be tracking". **Not** a liveness signal (never written if the + phone dies). +- `gpsLastUpdateTime` (ms epoch) — refreshed with every fix; the actual + liveness signal. "Stale > 5 min" tables/alarms must key off this, via + dashboard cell styling or a rule chain (user-space config, no code). +- `gpsTrackedBy` (user email) — who is tracking; useful for fleets. + +## Phasing + +1. **Phase 1a — background spike (go/no-go gate).** Flutter only, hardcoded + config, no ThingsBoard changes. Real Android + iOS devices, ~30 min + locked-screen run streaming fixes and saving telemetry (to the current + user entity) to validate: foreground service + notification, iOS + background mode, permission flows, REST saves from background, battery + behavior. Includes all manifest/plist/permission groundwork. +2. **Phase 1b — one-shot save.** `getLocation` descriptor extension + editor + UI + web-side resolve-and-save. Flutter: add accuracy/timestamp to the + location result payload (currently dropped by the result mapper). Ships + independently. +3. **Phase 1c — live tracking.** New action types in ui-ngx (editor + + dispatch), Flutter tracking service, status bar, session screen, system + attributes, background mode from 1a. +4. **Phase 2 — hardening.** Offline buffering with timestamp backfill, + battery level (`gpsBatteryLevel`) / speed / heading extras, CE→PE merges + finalized, app-bundle-level config (backend) only if a real need appears. + +## Error handling + +- Permission denied / services disabled: existing sealed `LocationFix` + failure cases surface as action errors (one-shot) or session-screen / + status-bar error states (tracking), with `openAppSettings()` escape hatch. +- REST save failures during tracking: keep tracking, surface last error in + the session screen; buffering is Phase 2. +- Old app + new action type: `UnknownAction` → explicit error dialog on the + dashboard (by design). + +## Testing + +- Dart: unit tests for the tracking service (fake `ILocationService` + + fake client), widget tests for bar/session screen states. +- ui-ngx: editor form logic tests following existing action-editor specs. +- Manual device matrix for background behavior (Phase 1a protocol above); + PE solution-template smoke test after CE→PE merge. From cd13fbb5a45d630de107cdb3b92bf615eb2413ee Mon Sep 17 00:00:00 2001 From: ababak Date: Fri, 3 Jul 2026 15:58:20 +0300 Subject: [PATCH 06/40] docs: add phase 1b one-shot location save implementation plan --- .../plans/2026-07-03-gps-one-shot-save.md | 725 ++++++++++++++++++ 1 file changed, 725 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-03-gps-one-shot-save.md diff --git a/docs/superpowers/plans/2026-07-03-gps-one-shot-save.md b/docs/superpowers/plans/2026-07-03-gps-one-shot-save.md new file mode 100644 index 00000000..4ffa5fc4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-gps-one-shot-save.md @@ -0,0 +1,725 @@ +# GPS One-Shot Save (Phase 1b) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend the `getLocation` mobile action so a dashboard can declaratively save the phone's coordinates to a configurable target entity (attributes or timeseries) — no hand-edited JS. + +**Architecture:** The save block is stored in the widget action descriptor (dashboard JSON). At click time the web runtime (`widget.component.ts`) receives lat/lng (+accuracy/ts) from the mobile app over the existing `tbMobileHandler` bridge, resolves the target entity (current entity / current user / alias by name / from attribute value), and saves via `AttributeService`. The mobile app change is payload-only. Fully backward compatible: `saveToEntity` absent → today's behavior; old mobile apps just omit accuracy/ts. + +**Tech Stack:** Angular 18 reactive forms (ui-ngx), RxJS, Flutter/Dart. + +## Global Constraints + +- Spec: `docs/superpowers/specs/2026-07-03-gps-tracking-design.md` (phase 1b section). +- ui-ngx repo: `/home/artem/projects/thingsboard`, branch `feat/gps-tracker`. Flutter repo: `/home/artem/projects/mobile/flutter_thingsboard_app`, branch `feat/gps-tracker`. +- Conventional Commits; **no** `Co-Authored-By` lines. +- Dart: run `dart format ` before committing; `flutter analyze` must stay clean for changed files. +- ui-ngx: no component unit-test harness is in active use for these widget-settings components; verification is `npx tsc --noEmit -p src/tsconfig.app.json` (type check) + manual smoke test (Task 5). This is the repo convention — do not scaffold karma tests. +- Alias targeting stores the alias **name** (plain text input), not an alias id picker: the editor (`WidgetActionCallbacks`) has no alias-controller access and is also used by the SCADA symbol editor where no dashboard aliases exist. Resolution happens at click time via `widgetContext.aliasController`. +- Default attribute/telemetry keys: `latitude` / `longitude`. Metadata keys (when enabled): `gpsAccuracy`, `gpsTimestamp`. + +--- + +### Task 1: Flutter — add accuracy/ts to the location result payload + +**Files:** +- Modify: `lib/utils/services/mobile_actions/results/location_result.dart` +- Modify: `lib/utils/services/mobile_actions/mobile_action_result.dart` (location factory) +- Modify: `lib/utils/services/mobile_actions/actions/location_action_result_mapper.dart` +- Test: `test/utils/services/mobile_actions/location_action_result_mapper_test.dart` (new; first test in the repo — `test/` does not exist yet) + +**Interfaces:** +- Consumes: `GeoPosition` (`lib/utils/services/location/model/geo_position.dart`) — freezed, fields `latitude` (double), `longitude` (double), `accuracy` (double), `timestamp` (DateTime?). +- Produces: result JSON `{hasResult: true, result: {latitude, longitude, accuracy?, ts?}}` — Task 2's `MobileLocationResult` mirrors this; Task 4 reads `actionResult.accuracy` / `actionResult.ts`. + +- [ ] **Step 1: Write the failing test** + +Create `test/utils/services/mobile_actions/location_action_result_mapper_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/actions/location_action_result_mapper.dart'; + +class _Mapper with LocationActionResultMapper {} + +void main() { + test('LocationSuccess maps to success json with accuracy and ts', () { + final result = _Mapper().mapLocationFixToResult( + LocationSuccess( + GeoPosition( + latitude: 50.45, + longitude: 30.52, + accuracy: 12.5, + timestamp: DateTime.fromMillisecondsSinceEpoch(1720000000000), + ), + ), + ); + + final json = result.toJson(); + expect(json['hasResult'], true); + expect(json['hasError'], false); + expect(json['result']['latitude'], 50.45); + expect(json['result']['longitude'], 30.52); + expect(json['result']['accuracy'], 12.5); + expect(json['result']['ts'], 1720000000000); + }); + + test('LocationSuccess without timestamp omits ts key', () { + final result = _Mapper().mapLocationFixToResult( + LocationSuccess( + GeoPosition(latitude: 1, longitude: 2, accuracy: 3, timestamp: null), + ), + ); + + final json = result.toJson(); + expect(json['result'].containsKey('ts'), false); + expect(json['result']['accuracy'], 3); + }); + + test('LocationPermissionDenied maps to error result', () { + final result = + _Mapper().mapLocationFixToResult(const LocationPermissionDenied()); + + final json = result.toJson(); + expect(json['hasError'], true); + expect(json['hasResult'], false); + }); +} +``` + +Note: if `GeoPosition`'s constructor differs (check `lib/utils/services/location/model/geo_position.dart` — freezed, named params), adjust the test construction, not the assertions. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd /home/artem/projects/mobile/flutter_thingsboard_app && flutter test test/utils/services/mobile_actions/location_action_result_mapper_test.dart` +Expected: FAIL — `json['result']['accuracy']` is null (payload has no accuracy key yet). + +- [ ] **Step 3: Implement the payload extension** + +`lib/utils/services/mobile_actions/results/location_result.dart` — replace the class body: + +```dart +import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action_result.dart'; + +class LocationResult extends MobileActionResult { + LocationResult(this.latitude, this.longitude, {this.accuracy, this.ts}); + num latitude; + num longitude; + num? accuracy; + int? ts; + + @override + Map toJson() { + final json = super.toJson(); + json['latitude'] = latitude; + json['longitude'] = longitude; + if (accuracy != null) { + json['accuracy'] = accuracy; + } + if (ts != null) { + json['ts'] = ts; + } + return json; + } +} +``` + +`lib/utils/services/mobile_actions/mobile_action_result.dart` — replace the location factory: + +```dart + factory MobileActionResult.location( + num latitude, + num longitude, { + num? accuracy, + int? ts, + }) { + return LocationResult(latitude, longitude, accuracy: accuracy, ts: ts); + } +``` + +`lib/utils/services/mobile_actions/actions/location_action_result_mapper.dart` — replace the `LocationSuccess` arm: + +```dart + LocationSuccess(:final position) => + WidgetMobileActionResult.successResult( + MobileActionResult.location( + position.latitude, + position.longitude, + accuracy: position.accuracy, + ts: position.timestamp?.millisecondsSinceEpoch, + ), + ), +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/utils/services/mobile_actions/location_action_result_mapper_test.dart` +Expected: PASS (3 tests). + +- [ ] **Step 5: Format, analyze, commit** + +```bash +cd /home/artem/projects/mobile/flutter_thingsboard_app +dart format lib/utils/services/mobile_actions/ test/ +flutter analyze 2>&1 | grep -E "mobile_actions|location_" ; echo "expect no output above" +git add lib/utils/services/mobile_actions/ test/ +git commit -m "feat(location): include accuracy and timestamp in getLocation action result" +``` + +--- + +### Task 2: ui-ngx — descriptor model + locale keys + +**Files:** +- Modify: `ui-ngx/src/app/shared/models/widget.models.ts` (~lines 722-786: `MobileLocationResult`, new enums/interfaces, `GetLocationDescriptor`) +- Modify: `ui-ngx/src/assets/locale/locale.constant-en_US.json` (the `widget-action.mobile` object, ~line 7738-7763) + +**Interfaces:** +- Produces (used by Tasks 3 and 4): + - `MobileActionTargetEntityType` enum: `currentEntity='CURRENT_ENTITY' | currentUser='CURRENT_USER' | entityAlias='ENTITY_ALIAS' | fromAttribute='FROM_ATTRIBUTE'` + - `MobileActionAttributeSource` enum: `currentEntity='CURRENT_ENTITY' | currentUser='CURRENT_USER'` + - `MobileActionSaveAs` enum: `attributes='ATTRIBUTES' | timeseries='TIMESERIES'` + - `MobileActionTargetEntityConfig { type; aliasName?; attributeSource?; attributeKey?; defaultEntityType?: EntityType }` + - `SaveLocationDescriptor { saveToEntity?; targetEntity?; saveAs?; latitudeKey?; longitudeKey?; includeMetadata? }` + - `GetLocationDescriptor extends SaveLocationDescriptor` (keeps `processLocationFunction`) + - `MobileLocationResult` gains `accuracy?: number; ts?: number` + - Translation maps: `mobileActionTargetEntityTypeTranslationMap`, `mobileActionAttributeSourceTranslationMap`, `mobileActionSaveAsTranslationMap` + +- [ ] **Step 1: Extend `MobileLocationResult`** + +In `ui-ngx/src/app/shared/models/widget.models.ts` replace: + +```typescript +export interface MobileLocationResult { + latitude: number; + longitude: number; +} +``` + +with: + +```typescript +export interface MobileLocationResult { + latitude: number; + longitude: number; + accuracy?: number; + ts?: number; +} +``` + +- [ ] **Step 2: Add target-entity/save enums and descriptor** + +In the same file, directly above `export interface GetLocationDescriptor`, insert: + +```typescript +export enum MobileActionTargetEntityType { + currentEntity = 'CURRENT_ENTITY', + currentUser = 'CURRENT_USER', + entityAlias = 'ENTITY_ALIAS', + fromAttribute = 'FROM_ATTRIBUTE' +} + +export const mobileActionTargetEntityTypeTranslationMap = new Map( + [ + [ MobileActionTargetEntityType.currentEntity, 'widget-action.mobile.target-current-entity' ], + [ MobileActionTargetEntityType.currentUser, 'widget-action.mobile.target-current-user' ], + [ MobileActionTargetEntityType.entityAlias, 'widget-action.mobile.target-entity-alias' ], + [ MobileActionTargetEntityType.fromAttribute, 'widget-action.mobile.target-from-attribute' ] + ] +); + +export enum MobileActionAttributeSource { + currentEntity = 'CURRENT_ENTITY', + currentUser = 'CURRENT_USER' +} + +export const mobileActionAttributeSourceTranslationMap = new Map( + [ + [ MobileActionAttributeSource.currentEntity, 'widget-action.mobile.target-current-entity' ], + [ MobileActionAttributeSource.currentUser, 'widget-action.mobile.target-current-user' ] + ] +); + +export enum MobileActionSaveAs { + attributes = 'ATTRIBUTES', + timeseries = 'TIMESERIES' +} + +export const mobileActionSaveAsTranslationMap = new Map( + [ + [ MobileActionSaveAs.attributes, 'widget-action.mobile.save-as-attributes' ], + [ MobileActionSaveAs.timeseries, 'widget-action.mobile.save-as-timeseries' ] + ] +); + +export interface MobileActionTargetEntityConfig { + type: MobileActionTargetEntityType; + aliasName?: string; + attributeSource?: MobileActionAttributeSource; + attributeKey?: string; + defaultEntityType?: EntityType; +} + +export interface SaveLocationDescriptor { + saveToEntity?: boolean; + targetEntity?: MobileActionTargetEntityConfig; + saveAs?: MobileActionSaveAs; + latitudeKey?: string; + longitudeKey?: string; + includeMetadata?: boolean; +} +``` + +and replace: + +```typescript +export interface GetLocationDescriptor { + processLocationFunction: TbFunction; +} +``` + +with: + +```typescript +export interface GetLocationDescriptor extends SaveLocationDescriptor { + processLocationFunction: TbFunction; +} +``` + +Ensure `EntityType` is imported in `widget.models.ts` (`import { EntityType } from '@shared/models/entity-type.models';`) — add to the existing import if missing. + +- [ ] **Step 3: Add locale keys** + +In `ui-ngx/src/assets/locale/locale.constant-en_US.json`, inside the `widget-action` → `mobile` object (after `"soft-ap": "Soft AP"` — add a trailing comma to it), insert: + +```json + "save-to-entity": "Save location to entity", + "target-entity-type": "Target entity", + "target-current-entity": "Current entity", + "target-current-user": "Current user", + "target-entity-alias": "Entity alias", + "target-from-attribute": "From attribute value", + "target-entity-alias-name": "Alias name", + "target-entity-alias-name-required": "Alias name is required", + "target-attribute-source": "Read attribute from", + "target-attribute-key": "Attribute key", + "target-attribute-key-required": "Attribute key is required", + "target-default-entity-type": "Entity type of attribute value", + "save-as": "Save as", + "save-as-attributes": "Attributes (server scope)", + "save-as-timeseries": "Time series", + "latitude-key": "Latitude key", + "longitude-key": "Longitude key", + "include-metadata": "Also save accuracy and timestamp", + "location-saved": "Location saved", + "location-save-failed": "Failed to save location: {{error}}" +``` + +- [ ] **Step 4: Type-check** + +Run: `cd /home/artem/projects/thingsboard/ui-ngx && npx tsc --noEmit -p src/tsconfig.app.json` +Expected: exits 0 (same diagnostics as before the change; no new errors). If the tsconfig path is rejected, use `yarn build` instead (slower). + +- [ ] **Step 5: Commit** + +```bash +cd /home/artem/projects/thingsboard +git add ui-ngx/src/app/shared/models/widget.models.ts ui-ngx/src/assets/locale/locale.constant-en_US.json +git commit -m "feat(mobile-actions): add declarative save-to-entity model for getLocation action" +``` + +--- + +### Task 3: ui-ngx — mobile action editor form + template + +**Files:** +- Modify: `ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.component.ts` +- Modify: `ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.component.html` + +**Interfaces:** +- Consumes: Task 2's enums/maps and `MobileActionTargetEntityConfig`. +- Produces: descriptor persisted from `mobileActionTypeFormGroup.getRawValue()` — flat keys `saveToEntity`, `saveAs`, `latitudeKey`, `longitudeKey`, `includeMetadata` and nested group `targetEntity` `{type, aliasName, attributeSource, attributeKey, defaultEntityType}`. Task 4 reads exactly these names off `WidgetMobileActionDescriptor`. + +- [ ] **Step 1: Add imports and class fields** + +In `mobile-action-editor.component.ts`, extend the `@shared/models/widget.models` import with: `MobileActionAttributeSource`, `mobileActionAttributeSourceTranslationMap`, `MobileActionSaveAs`, `mobileActionSaveAsTranslationMap`, `MobileActionTargetEntityType`, `mobileActionTargetEntityTypeTranslationMap`. + +Add class fields after `provisionTypeTranslationMap` (line ~80): + +```typescript + targetEntityTypes = Object.values(MobileActionTargetEntityType); + targetEntityTypeTranslations = mobileActionTargetEntityTypeTranslationMap; + targetEntityType = MobileActionTargetEntityType; + + attributeSources = Object.values(MobileActionAttributeSource); + attributeSourceTranslations = mobileActionAttributeSourceTranslationMap; + + saveAsOptions = Object.values(MobileActionSaveAs); + saveAsTranslations = mobileActionSaveAsTranslationMap; +``` + +- [ ] **Step 2: Build the save-location controls in the `getLocation` case** + +In `updateMobileActionType`, `case WidgetMobileActionType.getLocation:` — after the existing `addControl('processLocationFunction', ...)` call and before `break;`, insert: + +```typescript + const targetEntity = action?.targetEntity; + this.mobileActionTypeFormGroup.addControl( + 'saveToEntity', + this.fb.control(action?.saveToEntity || false, []) + ); + this.mobileActionTypeFormGroup.addControl( + 'targetEntity', + this.fb.group({ + type: [targetEntity?.type || MobileActionTargetEntityType.currentEntity, []], + aliasName: [targetEntity?.aliasName, []], + attributeSource: [targetEntity?.attributeSource || MobileActionAttributeSource.currentUser, []], + attributeKey: [targetEntity?.attributeKey, []], + defaultEntityType: [targetEntity?.defaultEntityType, []] + }) + ); + this.mobileActionTypeFormGroup.addControl( + 'saveAs', + this.fb.control(action?.saveAs || MobileActionSaveAs.attributes, []) + ); + this.mobileActionTypeFormGroup.addControl( + 'latitudeKey', + this.fb.control(action?.latitudeKey || 'latitude', []) + ); + this.mobileActionTypeFormGroup.addControl( + 'longitudeKey', + this.fb.control(action?.longitudeKey || 'longitude', []) + ); + this.mobileActionTypeFormGroup.addControl( + 'includeMetadata', + this.fb.control(action?.includeMetadata || false, []) + ); + this.updateSaveLocationValidators(); + this.mobileActionTypeFormGroup.get('saveToEntity').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => this.updateSaveLocationValidators()); + this.mobileActionTypeFormGroup.get('targetEntity.type').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => this.updateSaveLocationValidators()); +``` + +- [ ] **Step 3: Add the conditional validators helper** + +Add as a private method after `updateMobileActionType`: + +```typescript + private updateSaveLocationValidators() { + const saveToEntity: boolean = this.mobileActionTypeFormGroup.get('saveToEntity').value; + const type: MobileActionTargetEntityType = this.mobileActionTypeFormGroup.get('targetEntity.type').value; + const aliasName = this.mobileActionTypeFormGroup.get('targetEntity.aliasName'); + const attributeKey = this.mobileActionTypeFormGroup.get('targetEntity.attributeKey'); + aliasName.setValidators( + saveToEntity && type === MobileActionTargetEntityType.entityAlias ? [Validators.required] : []); + attributeKey.setValidators( + saveToEntity && type === MobileActionTargetEntityType.fromAttribute ? [Validators.required] : []); + aliasName.updateValueAndValidity({emitEvent: false}); + attributeKey.updateValueAndValidity({emitEvent: false}); + } +``` + +- [ ] **Step 4: Add the template block** + +In `mobile-action-editor.component.html`, inside the `` (after the `deviceProvision` `@if` block, before the `@for (config of actionConfig ...)` loop), insert: + +```html + @if (mobileActionFormGroup.get('type').value === mobileActionType.getLocation) { +
+ + {{ 'widget-action.mobile.save-to-entity' | translate }} + +
+ @if (mobileActionTypeFormGroup.get('saveToEntity').value) { + +
+
{{ 'widget-action.mobile.target-entity-type' | translate }}
+ + + + {{ targetEntityTypeTranslations.get(type) | translate }} + + + +
+ @if (mobileActionTypeFormGroup.get('targetEntity.type').value === targetEntityType.entityAlias) { +
+
{{ 'widget-action.mobile.target-entity-alias-name' | translate }}*
+ + + + warning + + +
+ } + @if (mobileActionTypeFormGroup.get('targetEntity.type').value === targetEntityType.fromAttribute) { +
+
{{ 'widget-action.mobile.target-attribute-source' | translate }}
+ + + + {{ attributeSourceTranslations.get(source) | translate }} + + + +
+
+
{{ 'widget-action.mobile.target-attribute-key' | translate }}*
+ + + + warning + + +
+
+
{{ 'widget-action.mobile.target-default-entity-type' | translate }}
+ + +
+ } +
+
+
{{ 'widget-action.mobile.save-as' | translate }}
+ + + + {{ saveAsTranslations.get(option) | translate }} + + + +
+
+
{{ 'widget-action.mobile.latitude-key' | translate }}
+ + + +
+
+
{{ 'widget-action.mobile.longitude-key' | translate }}
+ + + +
+
+ + {{ 'widget-action.mobile.include-metadata' | translate }} + +
+ } + } +``` + +- [ ] **Step 5: Type-check and commit** + +```bash +cd /home/artem/projects/thingsboard/ui-ngx && npx tsc --noEmit -p src/tsconfig.app.json +cd /home/artem/projects/thingsboard +git add ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/ +git commit -m "feat(mobile-actions): save-to-entity configuration UI for getLocation action" +``` + +--- + +### Task 4: ui-ngx — resolve target entity and save at runtime + +**Files:** +- Modify: `ui-ngx/src/app/modules/home/components/widget/widget.component.ts` (`getLocation` case ~line 1371; new private methods after `handleMobileAction`'s closing brace region, ~line 1470+) + +**Interfaces:** +- Consumes: Task 2's model (`mobileAction.saveToEntity`, `.targetEntity`, `.saveAs`, `.latitudeKey`, `.longitudeKey`, `.includeMetadata`; `actionResult.accuracy`, `.ts`); `this.widgetContext.aliasController` (`getEntityAliases(): EntityAliases`, `getAliasInfo(aliasId): Observable` with `currentEntity: EntityInfo {id, entityType}`); `this.widgetContext.attributeService` (`getEntityAttributes(entityId, scope, keys)`, `saveEntityAttributes(entityId, AttributeScope.SERVER_SCOPE, AttributeData[])`, `saveEntityTimeseries(entityId, 'scope', AttributeData[])` — the literal `'scope'` string matches `photo-camera-input.component.ts:261`, the path segment is ignored server-side); `getCurrentAuthUser(this.store)` from `@core/auth/auth.selectors`; `this.widgetContext.showSuccessToast/showErrorToast`; `this.translate` (already injected). +- Produces: server-side attributes/timeseries on the target entity; success/error toasts. + +- [ ] **Step 1: Add imports** + +In `widget.component.ts`: +- Extend the `@shared/models/widget.models` import with: `MobileActionAttributeSource`, `MobileActionSaveAs`, `MobileActionTargetEntityType`, `MobileLocationResult`. +- Add (or extend existing imports): `import { AttributeData, AttributeScope } from '@shared/models/telemetry/telemetry.models';`, `import { getCurrentAuthUser } from '@core/auth/auth.selectors';`, `EntityType` from `@shared/models/entity-type.models`, `throwError` in the `rxjs` import list, `isDefinedAndNotNull` in the `@core/utils` import list. Check each — several are likely already imported; add only what's missing. + +- [ ] **Step 2: Hook the save into the `getLocation` result case** + +In `handleMobileAction`, `case WidgetMobileActionType.getLocation:` (~line 1371), insert after `const longitude = actionResult.longitude;`: + +```typescript + if (mobileAction.saveToEntity) { + this.saveMobileActionLocation(mobileAction, actionResult, entityId); + } +``` + +The existing `processLocationFunction` invocation stays unchanged below it (both can run). + +- [ ] **Step 3: Add the resolve/save methods** + +Add as private methods on `WidgetComponent` (after `handleWidgetMobileActionError`): + +```typescript + private saveMobileActionLocation(mobileAction: WidgetMobileActionDescriptor, + locationResult: MobileLocationResult, + currentEntityId?: EntityId): void { + this.resolveMobileActionTargetEntity(mobileAction, currentEntityId).pipe( + switchMap((targetEntityId) => { + const data: Array = [ + {key: mobileAction.latitudeKey || 'latitude', value: locationResult.latitude}, + {key: mobileAction.longitudeKey || 'longitude', value: locationResult.longitude} + ]; + if (mobileAction.includeMetadata) { + if (isDefinedAndNotNull(locationResult.accuracy)) { + data.push({key: 'gpsAccuracy', value: locationResult.accuracy}); + } + if (isDefinedAndNotNull(locationResult.ts)) { + data.push({key: 'gpsTimestamp', value: locationResult.ts}); + } + } + if (mobileAction.saveAs === MobileActionSaveAs.timeseries) { + return this.widgetContext.attributeService.saveEntityTimeseries(targetEntityId, 'scope', data); + } else { + return this.widgetContext.attributeService.saveEntityAttributes(targetEntityId, AttributeScope.SERVER_SCOPE, data); + } + }) + ).subscribe({ + next: () => { + this.widgetContext.showSuccessToast(this.translate.instant('widget-action.mobile.location-saved')); + }, + error: (err) => { + const message = err?.message ? err.message : JSON.stringify(err); + this.widgetContext.showErrorToast( + this.translate.instant('widget-action.mobile.location-save-failed', {error: message})); + } + }); + } + + private resolveMobileActionTargetEntity(mobileAction: WidgetMobileActionDescriptor, + currentEntityId?: EntityId): Observable { + const target = mobileAction.targetEntity; + const type = target?.type || MobileActionTargetEntityType.currentEntity; + switch (type) { + case MobileActionTargetEntityType.currentEntity: + if (validateEntityId(currentEntityId)) { + return of(currentEntityId); + } + return throwError(() => new Error('Widget action has no current entity')); + case MobileActionTargetEntityType.currentUser: + return of(this.currentUserEntityId()); + case MobileActionTargetEntityType.entityAlias: { + const aliases = this.widgetContext.aliasController.getEntityAliases(); + const aliasId = Object.keys(aliases).find(id => aliases[id].alias === target.aliasName); + if (!aliasId) { + return throwError(() => new Error(`Entity alias '${target.aliasName}' not found in the dashboard`)); + } + return this.widgetContext.aliasController.getAliasInfo(aliasId).pipe( + map((aliasInfo) => { + const entity = aliasInfo.currentEntity; + if (!entity?.id || !entity?.entityType) { + throw new Error(`Entity alias '${target.aliasName}' did not resolve to an entity`); + } + return {entityType: entity.entityType, id: entity.id} as EntityId; + }) + ); + } + case MobileActionTargetEntityType.fromAttribute: { + let sourceEntityId: EntityId; + if (target.attributeSource === MobileActionAttributeSource.currentEntity) { + if (!validateEntityId(currentEntityId)) { + return throwError(() => new Error('Widget action has no current entity')); + } + sourceEntityId = currentEntityId; + } else { + sourceEntityId = this.currentUserEntityId(); + } + return this.widgetContext.attributeService.getEntityAttributes( + sourceEntityId, AttributeScope.SERVER_SCOPE, [target.attributeKey]).pipe( + map((attributes) => { + const attribute = attributes.find(a => a.key === target.attributeKey); + if (!attribute || !isDefinedAndNotNull(attribute.value)) { + throw new Error(`Attribute '${target.attributeKey}' not found on the source entity`); + } + return this.parseTargetEntityAttributeValue(attribute.value, target.defaultEntityType); + }) + ); + } + } + } + + private currentUserEntityId(): EntityId { + const authUser = getCurrentAuthUser(this.store); + return {entityType: EntityType.USER, id: authUser.userId}; + } + + private parseTargetEntityAttributeValue(value: any, defaultEntityType?: EntityType): EntityId { + if (typeof value === 'object' && value?.id && value?.entityType) { + return {entityType: value.entityType, id: value.id}; + } + if (typeof value === 'string') { + let parsed: any = null; + try { + parsed = JSON.parse(value); + } catch (e) {} + if (parsed?.id && parsed?.entityType) { + return {entityType: parsed.entityType, id: parsed.id}; + } + if (defaultEntityType) { + return {entityType: defaultEntityType, id: value}; + } + } + throw new Error('Attribute value does not identify a target entity ' + + '(expected {entityType, id} JSON or a UUID with a configured entity type)'); + } +``` + +- [ ] **Step 4: Type-check and commit** + +```bash +cd /home/artem/projects/thingsboard/ui-ngx && npx tsc --noEmit -p src/tsconfig.app.json +cd /home/artem/projects/thingsboard +git add ui-ngx/src/app/modules/home/components/widget/widget.component.ts +git commit -m "feat(mobile-actions): resolve target entity and save phone location on getLocation result" +``` + +--- + +### Task 5: End-to-end smoke test (manual, real device) + +**Files:** none (verification only). + +- [ ] **Step 1: Run the stack** + +- Backend: local ThingsBoard CE (user's existing dev setup). +- Frontend: `cd /home/artem/projects/thingsboard/ui-ngx && yarn start` (proxies to the local backend). +- Mobile: `flutter run` (debug) on the Android device, endpoint pointed at the same server (must be reachable from the phone — use the machine's LAN IP). + +- [ ] **Step 2: Configure the action** + +On a dashboard, add any widget with actions (e.g. a button/table), add an action → type **Mobile action** → **Get phone location**. Enable **Save location to entity**, target **Current user**, keys default, **Save as: Attributes**, enable metadata toggle. + +- [ ] **Step 3: Verify each target mode** + +1. **Current user**: trigger the action from the phone's dashboard view → expect success toast in the WebView and `latitude`/`longitude`/`gpsAccuracy`/`gpsTimestamp` server attributes on your user (check via ui-ngx or the phone). +2. **Entity alias**: create a single-entity alias (e.g. a test device) on the dashboard, switch target to alias + its name → trigger → attributes land on the device. +3. **From attribute**: on your user, create server attribute `trackedEntityId` = `{"entityType":"DEVICE","id":""}` → switch target to From attribute / Current user / `trackedEntityId` → trigger → attributes land on that device. +4. **Timeseries mode**: switch Save as to Time series → trigger → values appear as latest telemetry. +5. **Backward compat**: create a plain Get phone location action without the save toggle → behaves exactly as before (dialog from `processLocationFunction` default). +6. **Web browser (non-mobile)**: trigger from desktop browser → `handleNonMobileFallbackFunction` path unchanged (no save attempted, no errors in console). + +- [ ] **Step 4: Record results** + +Note any deviations; fix-forward small issues in the relevant task's files and amend/commit with `fix:` prefix. From 5c3956a9d037377bcd4983c6859dbfab9315d2d1 Mon Sep 17 00:00:00 2001 From: ababak Date: Fri, 3 Jul 2026 16:34:31 +0300 Subject: [PATCH 07/40] feat(location): include accuracy and timestamp in getLocation action result --- .../location_action_result_mapper.dart | 7 ++- .../mobile_actions/mobile_action_result.dart | 12 +++-- .../results/location_result.dart | 11 +++- .../location_action_result_mapper_test.dart | 53 +++++++++++++++++++ 4 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 test/utils/services/mobile_actions/location_action_result_mapper_test.dart diff --git a/lib/utils/services/mobile_actions/actions/location_action_result_mapper.dart b/lib/utils/services/mobile_actions/actions/location_action_result_mapper.dart index 88189668..3336d8ab 100644 --- a/lib/utils/services/mobile_actions/actions/location_action_result_mapper.dart +++ b/lib/utils/services/mobile_actions/actions/location_action_result_mapper.dart @@ -10,7 +10,12 @@ mixin LocationActionResultMapper { return switch (fix) { LocationSuccess(:final position) => WidgetMobileActionResult.successResult( - MobileActionResult.location(position.latitude, position.longitude), + MobileActionResult.location( + position.latitude, + position.longitude, + accuracy: position.accuracy, + ts: position.timestamp?.millisecondsSinceEpoch, + ), ), LocationServicesDisabled() => WidgetMobileActionResult.errorResult( 'Location services are disabled.', diff --git a/lib/utils/services/mobile_actions/mobile_action_result.dart b/lib/utils/services/mobile_actions/mobile_action_result.dart index 992b11c7..eeffaf57 100644 --- a/lib/utils/services/mobile_actions/mobile_action_result.dart +++ b/lib/utils/services/mobile_actions/mobile_action_result.dart @@ -3,7 +3,8 @@ import 'package:thingsboard_app/utils/services/mobile_actions/results/image_resu import 'package:thingsboard_app/utils/services/mobile_actions/results/launch_result.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/results/location_result.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/results/qr_code_result.dart'; -abstract class MobileActionResult { + +abstract class MobileActionResult { MobileActionResult(); factory MobileActionResult.launched(bool launched) { @@ -18,8 +19,13 @@ abstract class MobileActionResult { return QrCodeResult(code, format); } - factory MobileActionResult.location(num latitude, num longitude) { - return LocationResult(latitude, longitude); + factory MobileActionResult.location( + num latitude, + num longitude, { + num? accuracy, + int? ts, + }) { + return LocationResult(latitude, longitude, accuracy: accuracy, ts: ts); } factory MobileActionResult.provisioning(String deviceName) { diff --git a/lib/utils/services/mobile_actions/results/location_result.dart b/lib/utils/services/mobile_actions/results/location_result.dart index fdbd968f..335431e8 100644 --- a/lib/utils/services/mobile_actions/results/location_result.dart +++ b/lib/utils/services/mobile_actions/results/location_result.dart @@ -1,16 +1,23 @@ import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action_result.dart'; class LocationResult extends MobileActionResult { - - LocationResult(this.latitude, this.longitude); + LocationResult(this.latitude, this.longitude, {this.accuracy, this.ts}); num latitude; num longitude; + num? accuracy; + int? ts; @override Map toJson() { final json = super.toJson(); json['latitude'] = latitude; json['longitude'] = longitude; + if (accuracy != null) { + json['accuracy'] = accuracy; + } + if (ts != null) { + json['ts'] = ts; + } return json; } } diff --git a/test/utils/services/mobile_actions/location_action_result_mapper_test.dart b/test/utils/services/mobile_actions/location_action_result_mapper_test.dart new file mode 100644 index 00000000..b4dfd2ce --- /dev/null +++ b/test/utils/services/mobile_actions/location_action_result_mapper_test.dart @@ -0,0 +1,53 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/actions/location_action_result_mapper.dart'; + +class _Mapper with LocationActionResultMapper {} + +void main() { + test('LocationSuccess maps to success json with accuracy and ts', () { + final result = _Mapper().mapLocationFixToResult( + LocationSuccess( + GeoPosition( + latitude: 50.45, + longitude: 30.52, + accuracy: 12.5, + timestamp: DateTime.fromMillisecondsSinceEpoch(1720000000000), + ), + ), + ); + + final json = result.toJson(); + final resultJson = json['result'] as Map; + expect(json['hasResult'], true); + expect(json['hasError'], false); + expect(resultJson['latitude'], 50.45); + expect(resultJson['longitude'], 30.52); + expect(resultJson['accuracy'], 12.5); + expect(resultJson['ts'], 1720000000000); + }); + + test('LocationSuccess without timestamp omits ts key', () { + final result = _Mapper().mapLocationFixToResult( + const LocationSuccess( + GeoPosition(latitude: 1, longitude: 2, accuracy: 3), + ), + ); + + final json = result.toJson(); + final resultJson = json['result'] as Map; + expect(resultJson.containsKey('ts'), false); + expect(resultJson['accuracy'], 3); + }); + + test('LocationPermissionDenied maps to error result', () { + final result = _Mapper().mapLocationFixToResult( + const LocationPermissionDenied(), + ); + + final json = result.toJson(); + expect(json['hasError'], true); + expect(json['hasResult'], false); + }); +} From a14f9b7b92fb0012e15efc46901153a141c2a811 Mon Sep 17 00:00:00 2001 From: ababak Date: Fri, 3 Jul 2026 18:16:32 +0300 Subject: [PATCH 08/40] docs: add phase 1c live tracking implementation plan --- .../plans/2026-07-03-gps-live-tracking.md | 2264 +++++++++++++++++ 1 file changed, 2264 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-03-gps-live-tracking.md diff --git a/docs/superpowers/plans/2026-07-03-gps-live-tracking.md b/docs/superpowers/plans/2026-07-03-gps-live-tracking.md new file mode 100644 index 00000000..73f69754 --- /dev/null +++ b/docs/superpowers/plans/2026-07-03-gps-live-tracking.md @@ -0,0 +1,2264 @@ +# GPS Live Tracking (Phase 1c) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Start/stop continuous phone GPS tracking from a dashboard mobile action; the app streams positions to a target entity as telemetry (background-capable), with system status attributes, a persistent in-app tracking bar (Stop/Pause/Hide), and a session screen. + +**Architecture:** ThingsBoard owns configuration (two new mobile action types whose descriptor lives in dashboard JSON); the web runtime resolves the target entity at click time (reusing phase 1b's `resolveMobileActionTargetEntity`) and sends the app one fully-resolved config object over the existing `tbMobileHandler` bridge. The app owns the runtime: a GetIt singleton `LiveLocationTrackingService` consumes the background-capable `ILocationService.positionStream()` (phase 1a), saves each fix via a thin `ILiveTrackingRemote` wrapper around the Dart client, and exposes session state through a stream that a Riverpod provider bridges to the UI. + +**Tech Stack:** Angular 18 (ui-ngx), Flutter/Dart, Riverpod codegen (`@riverpod` + build_runner), Freezed, geolocator (already wrapped). + +## Global Constraints + +- Spec: `docs/superpowers/specs/2026-07-03-gps-tracking-design.md` (phase 1c section). Design deltas locked here: live tracking always saves **timeseries** with an optional `mirrorToAttributes` toggle (no attributes/timeseries choice); **pause** cancels the stream and writes `gpsActive=false`, resume restores both; a **terminal location failure** (permission/services) puts the session in `paused` with `lastError` instead of killing it; starting while a session is active **silently replaces** it (the spec's "prompt to replace" needs UI in the action path — deferred); the tracking bar lives in `NavigationPage` (main pages only), not the outer `RouteHanlderWidget` shell, so it never overlays login/auth pages. +- Repos/branches: ui-ngx `/home/artem/projects/thingsboard` branch `feat/gps-tracker`; Flutter `/home/artem/projects/mobile/flutter_thingsboard_app` branch `feat/gps-tracker`. Leave the pre-existing uncommitted `ui-ngx/proxy.conf.js` change alone. +- Conventional Commits; **no** `Co-Authored-By` lines. +- Dart: `dart format` on changed files (only the files you touched — never whole directories); `flutter analyze` must stay clean for changed files; codegen via `flutter pub run build_runner build --delete-conflicting-outputs`; l10n regeneration via `flutter pub run intl_utils:generate`. +- ui-ngx verification: `cd ui-ngx && npx tsc --noEmit -p src/tsconfig.app.json` — bar: only the 4 pre-existing photoswipe errors. No new test scaffolding for ui-ngx (repo convention). +- Production Flutter UI strings are localized (`S.of(context).key`, keys in `lib/l10n/intl_en.arb` only — other locales fall back). The debug spike page stays hardcoded/untouched. +- **Wire protocol** (web → app, single arg after the action type; sent as a JS object, arrives in Dart as a `Map`): + +```jsonc +{ + "target": {"entityType": "DEVICE", "id": ""}, + "latitudeKey": "latitude", + "longitudeKey": "longitude", + "includeMetadata": false, // adds gpsAccuracy/gpsAltitude/gpsSpeed/gpsHeading telemetry keys + "mirrorToAttributes": false, // also save the same values as SERVER_SCOPE attributes + "accuracy": "BALANCED", // HIGH | BALANCED | LOW + "distanceFilterMeters": 10, // nullable; null -> report every fix + "intervalSeconds": 30, // nullable; Android pacing hint only + "maxDurationMinutes": 60, // nullable; null -> manual stop only + "writeStatusAttributes": true, // gpsActive / gpsLastUpdateTime / gpsTrackedBy + "trackedBy": "user@example.com" // current user email (AuthUser.sub web-side) +} +``` + +- App reply contract: `startLiveLocation` → `{launched: true}` success result (or error result on bad config / start failure); `stopLiveLocation` → `{launched: true}` if a session was stopped, **empty result** if none active. +- Status attribute names (SERVER_SCOPE on the target entity): `gpsActive` (bool), `gpsLastUpdateTime` (ms epoch, per fix), `gpsTrackedBy` (email string). +- Action type strings on the wire: `startLiveLocation`, `stopLiveLocation` (the Dart `fromString` matches enum names case-insensitively, so Dart enum values must use exactly these names). + +--- + +### Task 1: Flutter — extend GeoPosition with altitude/speed/heading + +**Files:** +- Modify: `lib/utils/services/location/model/geo_position.dart` +- Modify: `lib/utils/services/location/location_service.dart` (`_toGeoPosition`, ~line 130) +- Test: `test/utils/services/mobile_actions/location_action_result_mapper_test.dart` (existing — must keep passing; no new assertions needed) + +**Interfaces:** +- Produces: `GeoPosition` gains optional `double? altitude`, `double? speed`, `double? heading`. Task 3's `_saveFix` reads them. + +- [ ] **Step 1: Extend the freezed model** + +Replace the factory in `lib/utils/services/location/model/geo_position.dart`: + +```dart +@freezed +abstract class GeoPosition with _$GeoPosition { + const factory GeoPosition({ + required double latitude, + required double longitude, + required double accuracy, + DateTime? timestamp, + double? altitude, + double? speed, + double? heading, + }) = _GeoPosition; +} +``` + +- [ ] **Step 2: Map the new fields in LocationService** + +In `lib/utils/services/location/location_service.dart`, replace `_toGeoPosition`: + +```dart + GeoPosition _toGeoPosition(Position p) => GeoPosition( + latitude: p.latitude, + longitude: p.longitude, + accuracy: p.accuracy, + timestamp: p.timestamp, + altitude: p.altitude, + speed: p.speed, + heading: p.heading, + ); +``` + +- [ ] **Step 3: Regenerate freezed output and verify** + +```bash +cd /home/artem/projects/mobile/flutter_thingsboard_app +flutter pub run build_runner build --delete-conflicting-outputs +flutter test test/ +flutter analyze 2>&1 | grep -E "geo_position|location_service" ; echo "expect no output above" +``` +Expected: build succeeds, existing 3 tests pass. + +- [ ] **Step 4: Format and commit** + +```bash +dart format lib/utils/services/location/model/geo_position.dart lib/utils/services/location/location_service.dart +git add lib/utils/services/location/ +git commit -m "feat(location): expose altitude, speed and heading on GeoPosition" +``` + +--- + +### Task 2: Flutter — LiveTrackingConfig wire model + +**Files:** +- Create: `lib/utils/services/live_location_tracking/model/live_tracking_config.dart` +- Test: `test/utils/services/live_location_tracking/live_tracking_config_test.dart` + +**Interfaces:** +- Consumes: `LocationAccuracyLevel` from `lib/utils/services/location/model/location_stream_settings.dart`. +- Produces (used by Tasks 3-5): `LiveTrackingTarget {entityType: String, id: String}`; `LiveTrackingConfig` with fields `target: LiveTrackingTarget`, `latitudeKey: String` (default 'latitude'), `longitudeKey: String` (default 'longitude'), `includeMetadata: bool` (default false), `mirrorToAttributes: bool` (default false), `accuracy: LocationAccuracyLevel` (default balanced), `distanceFilterMeters: int?`, `intervalSeconds: int?`, `maxDurationMinutes: int?`, `writeStatusAttributes: bool` (default true), `trackedBy: String?`; `LiveTrackingConfig.fromJson(Map)` throwing `FormatException` when `target` is missing/malformed. + +- [ ] **Step 1: Write the failing tests** + +Create `test/utils/services/live_location_tracking/live_tracking_config_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_stream_settings.dart'; + +void main() { + test('parses a full config', () { + final config = LiveTrackingConfig.fromJson(const { + 'target': {'entityType': 'DEVICE', 'id': 'abc-123'}, + 'latitudeKey': 'lat', + 'longitudeKey': 'lng', + 'includeMetadata': true, + 'mirrorToAttributes': true, + 'accuracy': 'HIGH', + 'distanceFilterMeters': 25, + 'intervalSeconds': 60, + 'maxDurationMinutes': 120, + 'writeStatusAttributes': false, + 'trackedBy': 'user@example.com', + }); + + expect(config.target.entityType, 'DEVICE'); + expect(config.target.id, 'abc-123'); + expect(config.latitudeKey, 'lat'); + expect(config.longitudeKey, 'lng'); + expect(config.includeMetadata, true); + expect(config.mirrorToAttributes, true); + expect(config.accuracy, LocationAccuracyLevel.high); + expect(config.distanceFilterMeters, 25); + expect(config.intervalSeconds, 60); + expect(config.maxDurationMinutes, 120); + expect(config.writeStatusAttributes, false); + expect(config.trackedBy, 'user@example.com'); + }); + + test('applies defaults for a minimal config', () { + final config = LiveTrackingConfig.fromJson(const { + 'target': {'entityType': 'USER', 'id': 'u-1'}, + }); + + expect(config.latitudeKey, 'latitude'); + expect(config.longitudeKey, 'longitude'); + expect(config.includeMetadata, false); + expect(config.mirrorToAttributes, false); + expect(config.accuracy, LocationAccuracyLevel.balanced); + expect(config.distanceFilterMeters, isNull); + expect(config.intervalSeconds, isNull); + expect(config.maxDurationMinutes, isNull); + expect(config.writeStatusAttributes, true); + expect(config.trackedBy, isNull); + }); + + test('unknown accuracy falls back to balanced', () { + final config = LiveTrackingConfig.fromJson(const { + 'target': {'entityType': 'USER', 'id': 'u-1'}, + 'accuracy': 'ULTRA', + }); + + expect(config.accuracy, LocationAccuracyLevel.balanced); + }); + + test('missing target throws FormatException', () { + expect( + () => LiveTrackingConfig.fromJson(const {'latitudeKey': 'lat'}), + throwsFormatException, + ); + }); + + test('malformed target throws FormatException', () { + expect( + () => LiveTrackingConfig.fromJson(const { + 'target': {'entityType': 'DEVICE'}, + }), + throwsFormatException, + ); + }); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/utils/services/live_location_tracking/live_tracking_config_test.dart` +Expected: FAIL — file/classes don't exist. + +- [ ] **Step 3: Implement the model** + +Create `lib/utils/services/live_location_tracking/model/live_tracking_config.dart`: + +```dart +import 'package:thingsboard_app/utils/services/location/model/location_stream_settings.dart'; + +class LiveTrackingTarget { + const LiveTrackingTarget({required this.entityType, required this.id}); + + factory LiveTrackingTarget.fromJson(Map json) { + final entityType = json['entityType']; + final id = json['id']; + if (entityType is! String || id is! String) { + throw const FormatException( + 'Live tracking target must contain entityType and id', + ); + } + return LiveTrackingTarget(entityType: entityType, id: id); + } + + final String entityType; + final String id; +} + +/// Fully-resolved live tracking session config received from the dashboard +/// (the web side resolves aliases/attributes to a concrete target entity +/// before sending — see the phase 1c wire protocol in the plan doc). +class LiveTrackingConfig { + const LiveTrackingConfig({ + required this.target, + this.latitudeKey = 'latitude', + this.longitudeKey = 'longitude', + this.includeMetadata = false, + this.mirrorToAttributes = false, + this.accuracy = LocationAccuracyLevel.balanced, + this.distanceFilterMeters, + this.intervalSeconds, + this.maxDurationMinutes, + this.writeStatusAttributes = true, + this.trackedBy, + }); + + factory LiveTrackingConfig.fromJson(Map json) { + final targetJson = json['target']; + if (targetJson is! Map) { + throw const FormatException('Live tracking config is missing target'); + } + return LiveTrackingConfig( + target: LiveTrackingTarget.fromJson( + Map.from(targetJson), + ), + latitudeKey: json['latitudeKey'] as String? ?? 'latitude', + longitudeKey: json['longitudeKey'] as String? ?? 'longitude', + includeMetadata: json['includeMetadata'] as bool? ?? false, + mirrorToAttributes: json['mirrorToAttributes'] as bool? ?? false, + accuracy: _accuracyFromString(json['accuracy'] as String?), + distanceFilterMeters: (json['distanceFilterMeters'] as num?)?.toInt(), + intervalSeconds: (json['intervalSeconds'] as num?)?.toInt(), + maxDurationMinutes: (json['maxDurationMinutes'] as num?)?.toInt(), + writeStatusAttributes: json['writeStatusAttributes'] as bool? ?? true, + trackedBy: json['trackedBy'] as String?, + ); + } + + final LiveTrackingTarget target; + final String latitudeKey; + final String longitudeKey; + final bool includeMetadata; + final bool mirrorToAttributes; + final LocationAccuracyLevel accuracy; + final int? distanceFilterMeters; + final int? intervalSeconds; + final int? maxDurationMinutes; + final bool writeStatusAttributes; + final String? trackedBy; + + static LocationAccuracyLevel _accuracyFromString(String? value) => + switch (value) { + 'HIGH' => LocationAccuracyLevel.high, + 'LOW' => LocationAccuracyLevel.low, + _ => LocationAccuracyLevel.balanced, + }; +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `flutter test test/utils/services/live_location_tracking/live_tracking_config_test.dart` +Expected: PASS (5 tests). + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format lib/utils/services/live_location_tracking/ test/utils/services/live_location_tracking/ +flutter analyze 2>&1 | grep live_tracking ; echo "expect no output above" +git add lib/utils/services/live_location_tracking/ test/utils/services/live_location_tracking/ +git commit -m "feat(location): add live tracking wire config model" +``` + +--- + +### Task 3: Flutter — tracking session service (the core) + +**Files:** +- Create: `lib/utils/services/live_location_tracking/model/live_tracking_session.dart` (freezed) +- Create: `lib/utils/services/live_location_tracking/i_live_tracking_remote.dart` +- Create: `lib/utils/services/live_location_tracking/live_tracking_remote.dart` +- Create: `lib/utils/services/live_location_tracking/i_live_location_tracking_service.dart` +- Create: `lib/utils/services/live_location_tracking/live_location_tracking_service.dart` +- Modify: `lib/locator.dart` (register remote + service after the `ILocationService` block, ~line 61) +- Test: `test/utils/services/live_location_tracking/live_location_tracking_service_test.dart` + +**Interfaces:** +- Consumes: `LiveTrackingConfig`/`LiveTrackingTarget` (Task 2), `GeoPosition` with altitude/speed/heading (Task 1), `ILocationService.positionStream({LocationStreamSettings settings})`, `LocationStreamSettings`/`BackgroundTrackingConfig`/`LocationAccuracyLevel`, sealed `LocationFix` cases, `ITbClientService.client.getTelemetryControllerApi()` (`saveEntityTelemetry`/`saveEntityAttributesV2`, string params, JSON-string body), `TbLogger`. +- Produces (used by Tasks 4-5): + - `enum LiveTrackingStatus { tracking, paused }` + - `LiveTrackingSession` (freezed): `config`, `status`, `startedAt: DateTime`, `fixCount: int`, `savedCount: int`, `saveErrorCount: int`, `lastFix: GeoPosition?`, `lastError: String?` + - `ILiveLocationTrackingService`: `LiveTrackingSession? get session`, `Stream get sessionStream`, `Future start(LiveTrackingConfig config)`, `Future stop()`, `Future pause()`, `Future resume()` + - `ILiveTrackingRemote`: `Future saveTelemetry(LiveTrackingTarget target, int ts, Map values)`, `Future saveAttributes(LiveTrackingTarget target, Map attributes)` + - GetIt registrations: `getIt()`, `getIt()` + +- [ ] **Step 1: Create the session model** + +Create `lib/utils/services/live_location_tracking/model/live_tracking_session.dart`: + +```dart +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; + +part 'live_tracking_session.freezed.dart'; + +enum LiveTrackingStatus { tracking, paused } + +@freezed +abstract class LiveTrackingSession with _$LiveTrackingSession { + const factory LiveTrackingSession({ + required LiveTrackingConfig config, + required LiveTrackingStatus status, + required DateTime startedAt, + @Default(0) int fixCount, + @Default(0) int savedCount, + @Default(0) int saveErrorCount, + GeoPosition? lastFix, + String? lastError, + }) = _LiveTrackingSession; +} +``` + +Run: `flutter pub run build_runner build --delete-conflicting-outputs` + +- [ ] **Step 2: Create the remote interface + implementation** + +Create `lib/utils/services/live_location_tracking/i_live_tracking_remote.dart`: + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; + +/// Thin REST boundary for live tracking saves. Kept as an interface so the +/// tracking service is unit-testable and the CE/PE client difference stays +/// behind one seam. +abstract interface class ILiveTrackingRemote { + /// Saves one timeseries sample: `{ts, values}` on the target entity. + Future saveTelemetry( + LiveTrackingTarget target, + int ts, + Map values, + ); + + /// Saves SERVER_SCOPE attributes on the target entity. + Future saveAttributes( + LiveTrackingTarget target, + Map attributes, + ); +} +``` + +Create `lib/utils/services/live_location_tracking/live_tracking_remote.dart`: + +```dart +import 'dart:convert'; + +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_remote.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; + +class LiveTrackingRemote implements ILiveTrackingRemote { + LiveTrackingRemote({required ITbClientService clientService}) + : _clientService = clientService; + + final ITbClientService _clientService; + + @override + Future saveTelemetry( + LiveTrackingTarget target, + int ts, + Map values, + ) async { + await _clientService.client.getTelemetryControllerApi().saveEntityTelemetry( + entityType: target.entityType, + entityId: target.id, + scope: 'ANY', + body: jsonEncode({'ts': ts, 'values': values}), + ); + } + + @override + Future saveAttributes( + LiveTrackingTarget target, + Map attributes, + ) async { + await _clientService.client + .getTelemetryControllerApi() + .saveEntityAttributesV2( + entityType: target.entityType, + entityId: target.id, + scope: 'SERVER_SCOPE', + body: jsonEncode(attributes), + ); + } +} +``` + +- [ ] **Step 3: Create the service interface** + +Create `lib/utils/services/live_location_tracking/i_live_location_tracking_service.dart`: + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; + +/// App-wide owner of at most one live GPS tracking session. Runs the +/// location stream, saves telemetry/attributes per fix, and exposes session +/// state for the tracking bar / session screen. +abstract interface class ILiveLocationTrackingService { + LiveTrackingSession? get session; + + /// Emits on every session change; emits `null` when tracking stops. + Stream get sessionStream; + + /// Starts a session, replacing any active one. + Future start(LiveTrackingConfig config); + + Future stop(); + + /// Suspends position updates without discarding the session; writes + /// `gpsActive=false` so the platform sees data flow honestly stopped. + Future pause(); + + Future resume(); +} +``` + +- [ ] **Step 4: Write the failing service tests** + +Create `test/utils/services/live_location_tracking/live_location_tracking_service_test.dart`: + +```dart +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_remote.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; +import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; +import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_stream_settings.dart'; + +class FakeLocationService implements ILocationService { + StreamController? controller; + LocationStreamSettings? lastSettings; + int streamRequests = 0; + + @override + Stream positionStream({ + LocationStreamSettings settings = const LocationStreamSettings(), + }) { + streamRequests++; + lastSettings = settings; + controller = StreamController(); + return controller!.stream; + } + + @override + Future getCurrentPosition() => throw UnimplementedError(); + + @override + Future openAppSettings() async => true; + + @override + Future openLocationSettings() async => true; +} + +class FakeRemote implements ILiveTrackingRemote { + final telemetryCalls = <(LiveTrackingTarget, int, Map)>[]; + final attributeCalls = <(LiveTrackingTarget, Map)>[]; + Object? throwOnTelemetry; + + @override + Future saveTelemetry( + LiveTrackingTarget target, + int ts, + Map values, + ) async { + if (throwOnTelemetry != null) { + throw throwOnTelemetry!; + } + telemetryCalls.add((target, ts, values)); + } + + @override + Future saveAttributes( + LiveTrackingTarget target, + Map attributes, + ) async { + attributeCalls.add((target, attributes)); + } +} + +void main() { + const target = LiveTrackingTarget(entityType: 'DEVICE', id: 'd-1'); + final fix = GeoPosition( + latitude: 1, + longitude: 2, + accuracy: 5, + timestamp: DateTime.fromMillisecondsSinceEpoch(1720000000000), + altitude: 100, + speed: 3, + heading: 90, + ); + + late FakeLocationService location; + late FakeRemote remote; + late LiveLocationTrackingService service; + + setUp(() { + location = FakeLocationService(); + remote = FakeRemote(); + service = LiveLocationTrackingService( + locationService: location, + remote: remote, + logger: TbLogger(), + ); + }); + + test('start emits a tracking session and writes status attributes', + () async { + await service.start( + const LiveTrackingConfig(target: target, trackedBy: 'me@tb.io'), + ); + + expect(service.session?.status, LiveTrackingStatus.tracking); + expect(remote.attributeCalls.single.$2, { + 'gpsActive': true, + 'gpsTrackedBy': 'me@tb.io', + }); + expect(location.lastSettings?.background, isNotNull); + }); + + test('start with writeStatusAttributes=false writes nothing', () async { + await service.start( + const LiveTrackingConfig(target: target, writeStatusAttributes: false), + ); + + expect(remote.attributeCalls, isEmpty); + }); + + test('fix saves telemetry with configured keys and gpsLastUpdateTime', + () async { + await service.start( + const LiveTrackingConfig( + target: target, + latitudeKey: 'lat', + longitudeKey: 'lng', + ), + ); + remote.attributeCalls.clear(); + + location.controller!.add(LocationSuccess(fix)); + await pumpEventQueue(); + + final (savedTarget, ts, values) = remote.telemetryCalls.single; + expect(savedTarget.id, 'd-1'); + expect(ts, 1720000000000); + expect(values, {'lat': 1.0, 'lng': 2.0}); + expect(remote.attributeCalls.single.$2, { + 'gpsLastUpdateTime': 1720000000000, + }); + expect(service.session?.fixCount, 1); + expect(service.session?.savedCount, 1); + expect(service.session?.lastFix, fix); + }); + + test('includeMetadata adds gps metadata telemetry keys', () async { + await service.start( + const LiveTrackingConfig( + target: target, + includeMetadata: true, + writeStatusAttributes: false, + ), + ); + + location.controller!.add(LocationSuccess(fix)); + await pumpEventQueue(); + + expect(remote.telemetryCalls.single.$3, { + 'latitude': 1.0, + 'longitude': 2.0, + 'gpsAccuracy': 5.0, + 'gpsAltitude': 100.0, + 'gpsSpeed': 3.0, + 'gpsHeading': 90.0, + }); + }); + + test('mirrorToAttributes copies values into the attribute save', () async { + await service.start( + const LiveTrackingConfig(target: target, mirrorToAttributes: true), + ); + remote.attributeCalls.clear(); + + location.controller!.add(LocationSuccess(fix)); + await pumpEventQueue(); + + expect(remote.attributeCalls.single.$2, { + 'latitude': 1.0, + 'longitude': 2.0, + 'gpsLastUpdateTime': 1720000000000, + }); + }); + + test('save failure increments saveErrorCount and keeps tracking', () async { + await service.start(const LiveTrackingConfig(target: target)); + remote.throwOnTelemetry = Exception('boom'); + + location.controller!.add(LocationSuccess(fix)); + await pumpEventQueue(); + + expect(service.session?.status, LiveTrackingStatus.tracking); + expect(service.session?.saveErrorCount, 1); + expect(service.session?.savedCount, 0); + expect(service.session?.lastError, contains('boom')); + }); + + test('pause cancels the stream and writes gpsActive=false; resume restores', + () async { + await service.start(const LiveTrackingConfig(target: target)); + remote.attributeCalls.clear(); + + await service.pause(); + expect(service.session?.status, LiveTrackingStatus.paused); + expect(remote.attributeCalls.single.$2, {'gpsActive': false}); + expect(location.controller!.hasListener, false); + + remote.attributeCalls.clear(); + await service.resume(); + expect(service.session?.status, LiveTrackingStatus.tracking); + expect(remote.attributeCalls.single.$2, {'gpsActive': true}); + expect(location.streamRequests, 2); + }); + + test('stop clears the session and writes gpsActive=false', () async { + await service.start(const LiveTrackingConfig(target: target)); + remote.attributeCalls.clear(); + final emissions = []; + final sub = service.sessionStream.listen(emissions.add); + + await service.stop(); + await pumpEventQueue(); + + expect(service.session, isNull); + expect(remote.attributeCalls.single.$2, {'gpsActive': false}); + expect(emissions.last, isNull); + await sub.cancel(); + }); + + test('terminal permission failure pauses the session with an error', + () async { + await service.start(const LiveTrackingConfig(target: target)); + + location.controller!.add(const LocationPermissionDenied()); + await pumpEventQueue(); + + expect(service.session?.status, LiveTrackingStatus.paused); + expect(service.session?.lastError, isNotNull); + }); + + test('maxDurationMinutes auto-stops the session', () { + fakeAsync((async) { + service.start( + const LiveTrackingConfig(target: target, maxDurationMinutes: 5), + ); + async.flushMicrotasks(); + expect(service.session, isNotNull); + + async.elapse(const Duration(minutes: 5, seconds: 1)); + async.flushMicrotasks(); + expect(service.session, isNull); + }); + }); +} +``` + +- [ ] **Step 5: Run tests to verify they fail** + +Run: `flutter test test/utils/services/live_location_tracking/live_location_tracking_service_test.dart` +Expected: FAIL — `LiveLocationTrackingService` doesn't exist. + +- [ ] **Step 6: Implement the service** + +Create `lib/utils/services/live_location_tracking/live_location_tracking_service.dart`: + +```dart +import 'dart:async'; + +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_remote.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; +import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; +import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_stream_settings.dart'; + +class LiveLocationTrackingService implements ILiveLocationTrackingService { + LiveLocationTrackingService({ + required ILocationService locationService, + required ILiveTrackingRemote remote, + required TbLogger logger, + // Android notification strings are OS-level, set once at construction; + // English defaults are acceptable for v1 (the locator can later pass + // localized strings without touching this class). + this.backgroundConfig = const BackgroundTrackingConfig( + notificationTitle: 'ThingsBoard', + notificationText: 'Live location tracking is active', + ), + }) : _locationService = locationService, + _remote = remote, + _log = logger; + + final ILocationService _locationService; + final ILiveTrackingRemote _remote; + final TbLogger _log; + final BackgroundTrackingConfig backgroundConfig; + + final _sessionController = StreamController.broadcast(); + LiveTrackingSession? _session; + StreamSubscription? _subscription; + Timer? _maxDurationTimer; + + @override + LiveTrackingSession? get session => _session; + + @override + Stream get sessionStream => _sessionController.stream; + + @override + Future start(LiveTrackingConfig config) async { + await stop(); + _setSession( + LiveTrackingSession( + config: config, + status: LiveTrackingStatus.tracking, + startedAt: DateTime.now(), + ), + ); + await _writeStatusAttributes({ + 'gpsActive': true, + if (config.trackedBy != null) 'gpsTrackedBy': config.trackedBy, + }); + _subscribe(config); + final maxDuration = config.maxDurationMinutes; + if (maxDuration != null) { + _maxDurationTimer = Timer(Duration(minutes: maxDuration), stop); + } + } + + @override + Future stop() async { + _maxDurationTimer?.cancel(); + _maxDurationTimer = null; + await _subscription?.cancel(); + _subscription = null; + if (_session != null) { + await _writeStatusAttributes({'gpsActive': false}); + _setSession(null); + } + } + + @override + Future pause() async { + final current = _session; + if (current == null || current.status != LiveTrackingStatus.tracking) { + return; + } + await _subscription?.cancel(); + _subscription = null; + _setSession(current.copyWith(status: LiveTrackingStatus.paused)); + await _writeStatusAttributes({'gpsActive': false}); + } + + @override + Future resume() async { + final current = _session; + if (current == null || current.status != LiveTrackingStatus.paused) { + return; + } + _setSession( + current.copyWith(status: LiveTrackingStatus.tracking, lastError: null), + ); + await _writeStatusAttributes({'gpsActive': true}); + _subscribe(current.config); + } + + void _subscribe(LiveTrackingConfig config) { + _subscription = _locationService + .positionStream( + settings: LocationStreamSettings( + accuracy: config.accuracy, + distanceFilterMeters: config.distanceFilterMeters ?? 0, + interval: + config.intervalSeconds != null + ? Duration(seconds: config.intervalSeconds!) + : null, + background: backgroundConfig, + ), + ) + .listen(_onFix); + } + + Future _onFix(LocationFix fix) async { + final current = _session; + if (current == null) { + return; + } + switch (fix) { + case LocationSuccess(:final position): + _setSession( + current.copyWith(fixCount: current.fixCount + 1, lastFix: position), + ); + await _saveFix(current.config, position); + case LocationServicesDisabled(): + await _pauseWithError('Location services are disabled.'); + case LocationPermissionDenied(): + await _pauseWithError('Location permission denied.'); + case LocationPermissionDeniedForever(): + await _pauseWithError('Location permission permanently denied.'); + case LocationFixError(:final message): + _setSession(_session?.copyWith(lastError: message)); + } + } + + Future _saveFix(LiveTrackingConfig config, GeoPosition position) async { + final values = { + config.latitudeKey: position.latitude, + config.longitudeKey: position.longitude, + if (config.includeMetadata) ...{ + 'gpsAccuracy': position.accuracy, + if (position.altitude != null) 'gpsAltitude': position.altitude, + if (position.speed != null) 'gpsSpeed': position.speed, + if (position.heading != null) 'gpsHeading': position.heading, + }, + }; + final ts = + (position.timestamp ?? DateTime.now()).millisecondsSinceEpoch; + try { + await _remote.saveTelemetry(config.target, ts, values); + final attributes = { + if (config.mirrorToAttributes) ...values, + if (config.writeStatusAttributes) 'gpsLastUpdateTime': ts, + }; + if (attributes.isNotEmpty) { + await _remote.saveAttributes(config.target, attributes); + } + final current = _session; + if (current != null) { + _setSession(current.copyWith(savedCount: current.savedCount + 1)); + } + } catch (e, s) { + _log.error('LiveLocationTrackingService: save failed', e, s); + final current = _session; + if (current != null) { + _setSession( + current.copyWith( + saveErrorCount: current.saveErrorCount + 1, + lastError: e.toString(), + ), + ); + } + } + } + + Future _pauseWithError(String message) async { + final current = _session; + if (current == null) { + return; + } + await _subscription?.cancel(); + _subscription = null; + _setSession( + current.copyWith(status: LiveTrackingStatus.paused, lastError: message), + ); + await _writeStatusAttributes({'gpsActive': false}); + } + + Future _writeStatusAttributes(Map attributes) async { + final config = _session?.config; + if (config == null || !config.writeStatusAttributes) { + return; + } + try { + await _remote.saveAttributes(config.target, attributes); + } catch (e, s) { + _log.error('LiveLocationTrackingService: status attributes failed', e, s); + } + } + + void _setSession(LiveTrackingSession? session) { + _session = session; + _sessionController.add(session); + } +} +``` + +- [ ] **Step 7: Regenerate freezed, run tests** + +```bash +flutter pub run build_runner build --delete-conflicting-outputs +flutter test test/utils/services/live_location_tracking/ +``` +Expected: PASS (10 service tests + 5 config tests). + +- [ ] **Step 8: Register in GetIt** + +In `lib/locator.dart`, after the `ILocationService` registration block, add: + +```dart + getIt.registerLazySingleton( + () => LiveTrackingRemote(clientService: getIt()), + ); + getIt.registerLazySingleton( + () => LiveLocationTrackingService( + locationService: getIt(), + remote: getIt(), + logger: getIt(), + ), + ); +``` + +with imports: + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_remote.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_remote.dart'; +``` + +- [ ] **Step 9: Analyze, format, commit** + +```bash +dart format lib/utils/services/live_location_tracking/ lib/locator.dart test/utils/services/live_location_tracking/ +flutter analyze 2>&1 | grep -E "live_location|live_tracking|locator" ; echo "expect no output above" +git add lib/utils/services/live_location_tracking/ lib/locator.dart test/utils/services/live_location_tracking/ +git commit -m "feat(location): add live location tracking session service" +``` + +--- + +### Task 4: Flutter — start/stop mobile actions + +**Files:** +- Modify: `lib/utils/services/mobile_actions/widget_mobile_action_type.dart` +- Delete: `lib/utils/services/mobile_actions/actions/get_live_location_action.dart` +- Create: `lib/utils/services/mobile_actions/actions/start_live_location_action.dart` +- Create: `lib/utils/services/mobile_actions/actions/stop_live_location_action.dart` +- Modify: `lib/utils/services/mobile_actions/widget_action_handler.dart` (registration list) +- Test: `test/utils/services/mobile_actions/live_location_actions_test.dart` + +**Interfaces:** +- Consumes: `ILiveLocationTrackingService` via `getIt` (Task 3), `LiveTrackingConfig.fromJson` (Task 2), `MobileAction` base class (has `handleError(e)`), `WidgetMobileActionResult` factories, `MobileActionResult.launched(bool)`. +- Produces: enum values `startLiveLocation`, `stopLiveLocation` (replacing `getLiveLocation`); `fromString` matches them case-insensitively (existing mechanism). Bridge behavior per Global Constraints reply contract. + +- [ ] **Step 1: Write the failing tests** + +Create `test/utils/services/mobile_actions/live_location_actions_test.dart`: + +```dart +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/actions/start_live_location_action.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/actions/stop_live_location_action.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_type.dart'; + +class FakeController extends Fake implements InAppWebViewController {} + +class FakeTrackingService implements ILiveLocationTrackingService { + LiveTrackingConfig? startedWith; + bool stopped = false; + + @override + LiveTrackingSession? session; + + @override + Stream get sessionStream => const Stream.empty(); + + @override + Future start(LiveTrackingConfig config) async { + startedWith = config; + } + + @override + Future stop() async { + stopped = true; + } + + @override + Future pause() async {} + + @override + Future resume() async {} +} + +void main() { + late FakeTrackingService tracking; + + setUp(() { + tracking = FakeTrackingService(); + GetIt.I.registerLazySingleton( + () => tracking, + ); + }); + + tearDown(() async { + await GetIt.I.reset(); + }); + + test('action type strings parse to the new enum values', () { + expect( + WidgetMobileActionType.fromString('startLiveLocation'), + WidgetMobileActionType.startLiveLocation, + ); + expect( + WidgetMobileActionType.fromString('stopLiveLocation'), + WidgetMobileActionType.stopLiveLocation, + ); + }); + + test('start action parses config, starts service, returns launched', + () async { + final result = await StartLiveLocationAction().execute([ + 'startLiveLocation', + { + 'target': {'entityType': 'DEVICE', 'id': 'd-1'}, + 'trackedBy': 'me@tb.io', + }, + ], FakeController()); + + final json = result.toJson(); + expect(tracking.startedWith?.target.id, 'd-1'); + expect(json['hasResult'], true); + final resultJson = json['result'] as Map; + expect(resultJson['launched'], true); + }); + + test('start action with missing config returns an error result', () async { + final result = + await StartLiveLocationAction().execute(['startLiveLocation'], FakeController()); + + expect(result.toJson()['hasError'], true); + expect(tracking.startedWith, isNull); + }); + + test('start action with malformed target returns an error result', + () async { + final result = await StartLiveLocationAction().execute([ + 'startLiveLocation', + {'latitudeKey': 'lat'}, + ], FakeController()); + + expect(result.toJson()['hasError'], true); + }); + + test('stop action with active session stops and returns launched', + () async { + tracking.session = LiveTrackingSession( + config: const LiveTrackingConfig( + target: LiveTrackingTarget(entityType: 'DEVICE', id: 'd-1'), + ), + status: LiveTrackingStatus.tracking, + startedAt: DateTime.fromMillisecondsSinceEpoch(0), + ); + + final result = + await StopLiveLocationAction().execute(['stopLiveLocation'], FakeController()); + + expect(tracking.stopped, true); + expect(result.toJson()['hasResult'], true); + }); + + test('stop action with no session returns empty result', () async { + final result = + await StopLiveLocationAction().execute(['stopLiveLocation'], FakeController()); + + final json = result.toJson(); + expect(tracking.stopped, false); + expect(json['hasResult'], false); + expect(json['hasError'], false); + }); +} +``` + +Note: `MobileAction.execute` takes a non-nullable `InAppWebViewController`; the new actions never dereference it, so the test passes the `FakeController` (a `Fake` from flutter_test) defined at the top of the file. + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `flutter test test/utils/services/mobile_actions/live_location_actions_test.dart` +Expected: FAIL — enum values / action classes don't exist. + +- [ ] **Step 3: Update the enum** + +In `lib/utils/services/mobile_actions/widget_mobile_action_type.dart`, replace `getLiveLocation,` with: + +```dart + startLiveLocation, + stopLiveLocation, +``` + +- [ ] **Step 4: Replace the placeholder action with the two real ones** + +Delete `lib/utils/services/mobile_actions/actions/get_live_location_action.dart`. + +Create `lib/utils/services/mobile_actions/actions/start_live_location_action.dart`: + +```dart +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action_result.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_result.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_type.dart'; + +/// Starts a live tracking session from a fully-resolved dashboard config +/// (see the phase 1c wire protocol). Replaces any active session. +class StartLiveLocationAction extends MobileAction { + @override + Future execute( + List args, + InAppWebViewController controller, + ) async { + try { + if (args.length < 2 || args[1] is! Map) { + return WidgetMobileActionResult.errorResult( + 'Live tracking config is missing.', + ); + } + final config = LiveTrackingConfig.fromJson( + Map.from(args[1] as Map), + ); + await getIt().start(config); + return WidgetMobileActionResult.successResult( + MobileActionResult.launched(true), + ); + } on FormatException catch (e) { + return WidgetMobileActionResult.errorResult(e.message); + } catch (e) { + return handleError(e); + } + } + + @override + WidgetMobileActionType get type => WidgetMobileActionType.startLiveLocation; +} +``` + +Create `lib/utils/services/mobile_actions/actions/stop_live_location_action.dart`: + +```dart +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action_result.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_result.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_type.dart'; + +class StopLiveLocationAction extends MobileAction { + @override + Future execute( + List args, + InAppWebViewController controller, + ) async { + try { + final service = getIt(); + if (service.session == null) { + return WidgetMobileActionResult.emptyResult(); + } + await service.stop(); + return WidgetMobileActionResult.successResult( + MobileActionResult.launched(true), + ); + } catch (e) { + return handleError(e); + } + } + + @override + WidgetMobileActionType get type => WidgetMobileActionType.stopLiveLocation; +} +``` + +(If `handleError` is not defined on `MobileAction` — the old placeholder got it from the `LocationActionResultMapper` mixin — replace `return handleError(e);` with `return WidgetMobileActionResult.errorResult(e.toString());` in both actions. Check the base class first.) + +- [ ] **Step 5: Update the registration list** + +In `lib/utils/services/mobile_actions/widget_action_handler.dart`, replace the `GetLiveLocationAction()` entry (and its import) with: + +```dart + StartLiveLocationAction(), + StopLiveLocationAction(), +``` + +and imports: + +```dart +import 'package:thingsboard_app/utils/services/mobile_actions/actions/start_live_location_action.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/actions/stop_live_location_action.dart'; +``` + +- [ ] **Step 6: Run all tests, analyze, commit** + +```bash +flutter test test/ +flutter analyze 2>&1 | grep -E "mobile_actions|live_location" ; echo "expect no output above" +dart format lib/utils/services/mobile_actions/widget_mobile_action_type.dart lib/utils/services/mobile_actions/widget_action_handler.dart lib/utils/services/mobile_actions/actions/start_live_location_action.dart lib/utils/services/mobile_actions/actions/stop_live_location_action.dart test/utils/services/mobile_actions/live_location_actions_test.dart +git add lib/utils/services/mobile_actions/ test/utils/services/mobile_actions/ +git commit -m "feat(location): add startLiveLocation and stopLiveLocation mobile actions" +``` + +--- + +### Task 5: Flutter — Riverpod provider, tracking bar, session screen + +**Files:** +- Modify: `lib/l10n/intl_en.arb` (append keys before the closing `}`) +- Create: `lib/modules/location_tracking/presentation/provider/live_tracking_provider.dart` (+ generated `.g.dart`, `.freezed.dart`) +- Create: `lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart` +- Create: `lib/modules/location_tracking/presentation/view/live_tracking_session_page.dart` +- Modify: `lib/config/routes/v2/routes_config/routes/location_tracking_routes.dart` (add session route) +- Modify: `lib/modules/main/navigation_page.dart:64` (insert the bar) +- Test: `test/modules/location_tracking/live_tracking_bar_test.dart` + +**Interfaces:** +- Consumes: `ILiveLocationTrackingService` (session + sessionStream + stop/pause/resume), `LiveTrackingSession`/`LiveTrackingStatus`, `S.of(context)` l10n, go_router `context.push`. +- Produces: `liveTrackingProvider` (codegen from `class LiveTracking extends _$LiveTracking`) with state `LiveTrackingViewState {session: LiveTrackingSession?, hidden: bool}` and notifier methods `hide()`, `show()`, `stop()`, `pause()`, `resume()`; route constant `LocationTrackingRoutes.liveTrackingSession = '/liveTrackingSession'`. + +- [ ] **Step 1: Add l10n keys** + +In `lib/l10n/intl_en.arb`, before the final closing brace (add a comma to the current last entry), append: + +```json + "liveTrackingActive": "Live location tracking", + "liveTrackingPaused": "Live tracking paused", + "liveTrackingFixes": "Fixes", + "liveTrackingSaved": "Saved", + "liveTrackingErrors": "Errors", + "liveTrackingStop": "Stop", + "liveTrackingPause": "Pause", + "liveTrackingResume": "Resume", + "liveTrackingHide": "Hide", + "liveTrackingSessionTitle": "Live location tracking", + "liveTrackingNoSession": "No active tracking session", + "liveTrackingTarget": "Target entity", + "liveTrackingStatus": "Status", + "liveTrackingStarted": "Started", + "liveTrackingLastFix": "Last fix", + "liveTrackingLastError": "Last error" +``` + +Run: `flutter pub run intl_utils:generate` +Expected: `lib/generated/l10n.dart` gains the new getters (other locales fall back to English). + +- [ ] **Step 2: Create the provider** + +Create `lib/modules/location_tracking/presentation/provider/live_tracking_provider.dart`: + +```dart +import 'dart:async'; + +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; + +part 'live_tracking_provider.freezed.dart'; +part 'live_tracking_provider.g.dart'; + +@freezed +abstract class LiveTrackingViewState with _$LiveTrackingViewState { + const factory LiveTrackingViewState({ + LiveTrackingSession? session, + @Default(false) bool hidden, + }) = _LiveTrackingViewState; +} + +@riverpod +class LiveTracking extends _$LiveTracking { + late final StreamSubscription _listener; + + @override + LiveTrackingViewState build() { + final service = getIt(); + _listener = service.sessionStream.listen((session) { + state = LiveTrackingViewState( + session: session, + hidden: session == null ? false : state.hidden, + ); + }); + ref.onDispose(() => _listener.cancel()); + return LiveTrackingViewState(session: service.session); + } + + void hide() => state = state.copyWith(hidden: true); + + void show() => state = state.copyWith(hidden: false); + + Future stop() => getIt().stop(); + + Future pause() => getIt().pause(); + + Future resume() => getIt().resume(); +} +``` + +Run: `flutter pub run build_runner build --delete-conflicting-outputs` + +- [ ] **Step 3: Create the tracking bar** + +Create `lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:thingsboard_app/config/routes/v2/routes_config/routes/location_tracking_routes.dart'; +import 'package:thingsboard_app/generated/l10n.dart'; +import 'package:thingsboard_app/modules/location_tracking/presentation/provider/live_tracking_provider.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; + +/// Persistent bar shown on all main pages while a tracking session exists. +class LiveTrackingBar extends ConsumerWidget { + const LiveTrackingBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final viewState = ref.watch(liveTrackingProvider); + final session = viewState.session; + if (session == null) { + return const SizedBox.shrink(); + } + final colors = Theme.of(context).colorScheme; + final tracking = session.status == LiveTrackingStatus.tracking; + + if (viewState.hidden) { + return Material( + color: colors.primaryContainer, + child: InkWell( + onTap: () => ref.read(liveTrackingProvider.notifier).show(), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Icon( + tracking ? Icons.gps_fixed : Icons.gps_off, + size: 16, + color: colors.onPrimaryContainer, + ), + ), + ), + ); + } + + return Material( + color: colors.primaryContainer, + child: InkWell( + onTap: + () => context.push(LocationTrackingRoutes.liveTrackingSession), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row( + children: [ + Icon( + tracking ? Icons.gps_fixed : Icons.gps_off, + color: colors.onPrimaryContainer, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tracking + ? S.of(context).liveTrackingActive + : S.of(context).liveTrackingPaused, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: colors.onPrimaryContainer, + ), + ), + Text( + '${S.of(context).liveTrackingFixes}: ' + '${session.fixCount} · ' + '${S.of(context).liveTrackingSaved}: ' + '${session.savedCount}' + '${session.saveErrorCount > 0 ? ' · ${S.of(context).liveTrackingErrors}: ${session.saveErrorCount}' : ''}', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colors.onPrimaryContainer, + ), + ), + ], + ), + ), + IconButton( + tooltip: + tracking + ? S.of(context).liveTrackingPause + : S.of(context).liveTrackingResume, + icon: Icon( + tracking ? Icons.pause : Icons.play_arrow, + color: colors.onPrimaryContainer, + ), + onPressed: () { + final notifier = ref.read(liveTrackingProvider.notifier); + tracking ? notifier.pause() : notifier.resume(); + }, + ), + IconButton( + tooltip: S.of(context).liveTrackingStop, + icon: Icon(Icons.stop, color: colors.onPrimaryContainer), + onPressed: + () => ref.read(liveTrackingProvider.notifier).stop(), + ), + IconButton( + tooltip: S.of(context).liveTrackingHide, + icon: Icon( + Icons.expand_less, + color: colors.onPrimaryContainer, + ), + onPressed: + () => ref.read(liveTrackingProvider.notifier).hide(), + ), + ], + ), + ), + ), + ); + } +} +``` + +- [ ] **Step 4: Create the session screen and route** + +Create `lib/modules/location_tracking/presentation/view/live_tracking_session_page.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:thingsboard_app/generated/l10n.dart'; +import 'package:thingsboard_app/modules/location_tracking/presentation/provider/live_tracking_provider.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; + +class LiveTrackingSessionPage extends ConsumerWidget { + const LiveTrackingSessionPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final session = ref.watch(liveTrackingProvider).session; + return Scaffold( + appBar: AppBar(title: Text(S.of(context).liveTrackingSessionTitle)), + body: + session == null + ? Center(child: Text(S.of(context).liveTrackingNoSession)) + : _SessionDetails(session: session), + ); + } +} + +class _SessionDetails extends ConsumerWidget { + const _SessionDetails({required this.session}); + + final LiveTrackingSession session; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tracking = session.status == LiveTrackingStatus.tracking; + final lastFix = session.lastFix; + return ListView( + children: [ + ListTile( + title: Text(S.of(context).liveTrackingTarget), + subtitle: Text( + '${session.config.target.entityType} ${session.config.target.id}', + ), + ), + ListTile( + title: Text(S.of(context).liveTrackingStatus), + subtitle: Text( + tracking + ? S.of(context).liveTrackingActive + : S.of(context).liveTrackingPaused, + ), + ), + ListTile( + title: Text(S.of(context).liveTrackingStarted), + subtitle: Text(session.startedAt.toLocal().toString()), + ), + ListTile( + title: Text( + '${S.of(context).liveTrackingFixes}: ${session.fixCount} · ' + '${S.of(context).liveTrackingSaved}: ${session.savedCount} · ' + '${S.of(context).liveTrackingErrors}: ${session.saveErrorCount}', + ), + ), + if (lastFix != null) + ListTile( + title: Text(S.of(context).liveTrackingLastFix), + subtitle: Text( + '${lastFix.latitude.toStringAsFixed(6)}, ' + '${lastFix.longitude.toStringAsFixed(6)} ' + '(±${lastFix.accuracy.toStringAsFixed(0)} m)', + ), + ), + if (session.lastError != null) + ListTile( + title: Text(S.of(context).liveTrackingLastError), + subtitle: Text( + session.lastError!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () { + final notifier = ref.read(liveTrackingProvider.notifier); + tracking ? notifier.pause() : notifier.resume(); + }, + icon: Icon(tracking ? Icons.pause : Icons.play_arrow), + label: Text( + tracking + ? S.of(context).liveTrackingPause + : S.of(context).liveTrackingResume, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.icon( + onPressed: + () => ref.read(liveTrackingProvider.notifier).stop(), + icon: const Icon(Icons.stop), + label: Text(S.of(context).liveTrackingStop), + ), + ), + ], + ), + ), + ], + ); + } +} +``` + +In `lib/config/routes/v2/routes_config/routes/location_tracking_routes.dart`, add the constant and route: + +```dart +class LocationTrackingRoutes { + static const liveTrackingSpike = '/liveTrackingSpike'; + static const liveTrackingSession = '/liveTrackingSession'; +} +``` + +and in the list: + +```dart + GoRoute( + path: LocationTrackingRoutes.liveTrackingSession, + builder: (context, state) { + return const LiveTrackingSessionPage(); + }, + ), +``` + +with import `package:thingsboard_app/modules/location_tracking/presentation/view/live_tracking_session_page.dart`. + +- [ ] **Step 5: Insert the bar into the main shell** + +In `lib/modules/main/navigation_page.dart` line 64, replace `body: child,` with: + +```dart + body: Column( + children: [ + const LiveTrackingBar(), + Expanded(child: child), + ], + ), +``` + +Add import: `package:thingsboard_app/modules/location_tracking/presentation/widgets/live_tracking_bar.dart`. +(`child` here is the `NavigationPage` constructor's page content — confirm the local name in the build method; it may be `widget.child` or a local variable.) + +- [ ] **Step 6: Write the widget test** + +Create `test/modules/location_tracking/live_tracking_bar_test.dart`: + +```dart +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:thingsboard_app/generated/l10n.dart'; +import 'package:thingsboard_app/modules/location_tracking/presentation/widgets/live_tracking_bar.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; + +class FakeTrackingService implements ILiveLocationTrackingService { + final controller = StreamController.broadcast(); + bool stopCalled = false; + + @override + LiveTrackingSession? session; + + @override + Stream get sessionStream => controller.stream; + + @override + Future start(LiveTrackingConfig config) async {} + + @override + Future stop() async { + stopCalled = true; + } + + @override + Future pause() async {} + + @override + Future resume() async {} +} + +Widget _wrap(Widget child) => ProviderScope( + child: MaterialApp( + localizationsDelegates: const [S.delegate], + home: Scaffold(body: child), + ), +); + +void main() { + late FakeTrackingService tracking; + + setUp(() { + tracking = FakeTrackingService(); + GetIt.I.registerLazySingleton( + () => tracking, + ); + }); + + tearDown(() async { + await GetIt.I.reset(); + }); + + testWidgets('renders nothing without a session', (tester) async { + await tester.pumpWidget(_wrap(const LiveTrackingBar())); + + expect(find.byIcon(Icons.stop), findsNothing); + }); + + testWidgets('shows controls for an active session and stops on tap', + (tester) async { + tracking.session = LiveTrackingSession( + config: const LiveTrackingConfig( + target: LiveTrackingTarget(entityType: 'DEVICE', id: 'd-1'), + ), + status: LiveTrackingStatus.tracking, + startedAt: DateTime.fromMillisecondsSinceEpoch(0), + ); + + await tester.pumpWidget(_wrap(const LiveTrackingBar())); + await tester.pump(); + + expect(find.byIcon(Icons.stop), findsOneWidget); + expect(find.byIcon(Icons.pause), findsOneWidget); + + await tester.tap(find.byIcon(Icons.stop)); + expect(tracking.stopCalled, true); + }); +} +``` + +- [ ] **Step 7: Run tests, analyze, format, commit** + +```bash +flutter test test/ +flutter analyze 2>&1 | grep -E "location_tracking|navigation_page|intl_en" ; echo "expect no output above" +dart format lib/modules/location_tracking/ lib/config/routes/v2/routes_config/routes/location_tracking_routes.dart lib/modules/main/navigation_page.dart test/modules/location_tracking/ +git add lib/modules/location_tracking/ lib/config/routes/ lib/modules/main/navigation_page.dart lib/l10n/ lib/generated/ test/modules/location_tracking/ +git commit -m "feat(location): add live tracking bar, session screen and provider" +``` + +--- + +### Task 6: ui-ngx — action types, descriptor model, locale keys + +**Files:** +- Modify: `ui-ngx/src/app/shared/models/widget.models.ts` +- Modify: `ui-ngx/src/assets/locale/locale.constant-en_US.json` (`widget-action.mobile` object) + +**Interfaces:** +- Produces (used by Tasks 7-8): enum values `WidgetMobileActionType.startLiveLocation = 'startLiveLocation'` and `stopLiveLocation = 'stopLiveLocation'`; `MobileActionLocationAccuracy` enum (`high='HIGH'`, `balanced='BALANCED'`, `low='LOW'`) + `mobileActionLocationAccuracyTranslationMap`; `StartLiveLocationDescriptor extends ProcessLaunchResultDescriptor` with `targetEntity?: MobileActionTargetEntityConfig`, `latitudeKey?`, `longitudeKey?`, `includeMetadata?`, `mirrorToAttributes?`, `accuracy?: MobileActionLocationAccuracy`, `distanceFilterMeters?: number`, `intervalSeconds?: number`, `maxDurationMinutes?: number`, `writeStatusAttributes?: boolean`; the descriptor added to the `WidgetMobileActionDescriptors` intersection. + +- [ ] **Step 1: Extend the action type enum + translation map** + +In `widget.models.ts`, in `WidgetMobileActionType` after `getLocation = 'getLocation',` add: + +```typescript + startLiveLocation = 'startLiveLocation', + stopLiveLocation = 'stopLiveLocation', +``` + +In `widgetMobileActionTypeTranslationMap` after the `getLocation` entry add: + +```typescript + [ WidgetMobileActionType.startLiveLocation, 'widget-action.mobile.start-live-location' ], + [ WidgetMobileActionType.stopLiveLocation, 'widget-action.mobile.stop-live-location' ], +``` + +- [ ] **Step 2: Add the accuracy enum and descriptor** + +Directly below the `SaveLocationDescriptor` interface (added in phase 1b), insert: + +```typescript +export enum MobileActionLocationAccuracy { + high = 'HIGH', + balanced = 'BALANCED', + low = 'LOW' +} + +export const mobileActionLocationAccuracyTranslationMap = new Map( + [ + [ MobileActionLocationAccuracy.high, 'widget-action.mobile.accuracy-high' ], + [ MobileActionLocationAccuracy.balanced, 'widget-action.mobile.accuracy-balanced' ], + [ MobileActionLocationAccuracy.low, 'widget-action.mobile.accuracy-low' ] + ] +); + +export interface StartLiveLocationDescriptor extends ProcessLaunchResultDescriptor { + targetEntity?: MobileActionTargetEntityConfig; + latitudeKey?: string; + longitudeKey?: string; + includeMetadata?: boolean; + mirrorToAttributes?: boolean; + accuracy?: MobileActionLocationAccuracy; + distanceFilterMeters?: number; + intervalSeconds?: number; + maxDurationMinutes?: number; + writeStatusAttributes?: boolean; +} +``` + +Extend the intersection type by adding one line to `WidgetMobileActionDescriptors`: + +```typescript +export type WidgetMobileActionDescriptors = ProcessImageDescriptor & + LaunchMapDescriptor & + ScanQrCodeDescriptor & + MakePhoneCallDescriptor & + GetLocationDescriptor & + StartLiveLocationDescriptor & + ProvisionSuccessDescriptor; +``` + +Note: `StartLiveLocationDescriptor` and `SaveLocationDescriptor` (via `GetLocationDescriptor`) both declare `targetEntity`/`latitudeKey`/`longitudeKey`/`includeMetadata` with identical types — the intersection stays valid. + +- [ ] **Step 3: Add locale keys** + +In `locale.constant-en_US.json`, in the `widget-action.mobile` object (after the phase 1b keys, keeping JSON commas valid), add: + +```json + "start-live-location": "Start live location tracking", + "stop-live-location": "Stop live location tracking", + "accuracy": "Accuracy", + "accuracy-high": "High", + "accuracy-balanced": "Balanced", + "accuracy-low": "Low", + "distance-filter-meters": "Distance filter (meters)", + "interval-seconds": "Time interval (seconds)", + "max-duration-minutes": "Maximum duration (minutes)", + "mirror-to-attributes": "Also mirror latest values to attributes", + "write-status-attributes": "Write tracking status attributes (gpsActive, gpsLastUpdateTime, gpsTrackedBy)", + "include-metadata-live": "Also save accuracy, altitude, speed and heading" +``` + +- [ ] **Step 4: Verify and commit** + +```bash +cd /home/artem/projects/thingsboard/ui-ngx +npx tsc --noEmit -p src/tsconfig.app.json +node -e "JSON.parse(require('fs').readFileSync('src/assets/locale/locale.constant-en_US.json','utf8')); console.log('JSON OK')" +cd /home/artem/projects/thingsboard +git add ui-ngx/src/app/shared/models/widget.models.ts ui-ngx/src/assets/locale/locale.constant-en_US.json +git commit -m "feat(mobile-actions): add live location tracking action types and descriptor model" +``` +Expected: tsc shows only the 4 pre-existing photoswipe errors; JSON OK. + +--- + +### Task 7: ui-ngx — editor support for the new action types + +**Files:** +- Modify: `ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.component.ts` +- Modify: `ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.component.html` + +**Interfaces:** +- Consumes: Task 6's enums/descriptor; phase 1b's target-entity controls (currently inline in the `getLocation` case) and `updateSaveLocationValidators`. +- Produces: descriptor form output with the exact field names from Task 6; a reusable private `addTargetEntityControls(action)` + `updateTargetEntityValidators(targetRequired)` pair; an `` HTML block reused by both `getLocation` and `startLiveLocation`. + +- [ ] **Step 1: Extract the target-entity control builder (TS)** + +In `mobile-action-editor.component.ts`: + +Add imports to the `@shared/models/widget.models` import: `MobileActionLocationAccuracy`, `mobileActionLocationAccuracyTranslationMap`. + +Add class fields next to the existing 1b fields: + +```typescript + locationAccuracies = Object.values(MobileActionLocationAccuracy); + locationAccuracyTranslations = mobileActionLocationAccuracyTranslationMap; +``` + +Add two private methods (below `updateMobileActionType`), and delete the 1b `updateSaveLocationValidators` method (its logic moves into the second one): + +```typescript + private addTargetEntityControls(action?: WidgetMobileActionDescriptor) { + const targetEntity = action?.targetEntity; + this.mobileActionTypeFormGroup.addControl( + 'targetEntity', + this.fb.group({ + type: [targetEntity?.type || MobileActionTargetEntityType.currentEntity, []], + aliasName: [targetEntity?.aliasName, []], + attributeSource: [targetEntity?.attributeSource || MobileActionAttributeSource.currentUser, []], + attributeKey: [targetEntity?.attributeKey, []], + defaultEntityType: [targetEntity?.defaultEntityType, []] + }) + ); + this.mobileActionTypeFormGroup.addControl( + 'latitudeKey', + this.fb.control(action?.latitudeKey || 'latitude', []) + ); + this.mobileActionTypeFormGroup.addControl( + 'longitudeKey', + this.fb.control(action?.longitudeKey || 'longitude', []) + ); + this.mobileActionTypeFormGroup.addControl( + 'includeMetadata', + this.fb.control(action?.includeMetadata || false, []) + ); + } + + private updateTargetEntityValidators(targetRequired: boolean) { + const type: MobileActionTargetEntityType = this.mobileActionTypeFormGroup.get('targetEntity.type').value; + const aliasName = this.mobileActionTypeFormGroup.get('targetEntity.aliasName'); + const attributeKey = this.mobileActionTypeFormGroup.get('targetEntity.attributeKey'); + aliasName.setValidators( + targetRequired && type === MobileActionTargetEntityType.entityAlias ? [Validators.required] : []); + attributeKey.setValidators( + targetRequired && type === MobileActionTargetEntityType.fromAttribute ? [Validators.required] : []); + aliasName.updateValueAndValidity({emitEvent: false}); + attributeKey.updateValueAndValidity({emitEvent: false}); + } +``` + +- [ ] **Step 2: Rewrite the `getLocation` case to use the helpers** + +Replace the 1b-added block in `case WidgetMobileActionType.getLocation:` (everything from `const targetEntity = action?.targetEntity;` to the second `valueChanges` subscription, keeping `processLocationFunction` control untouched) with: + +```typescript + this.mobileActionTypeFormGroup.addControl( + 'saveToEntity', + this.fb.control(action?.saveToEntity || false, []) + ); + this.addTargetEntityControls(action); + this.mobileActionTypeFormGroup.addControl( + 'saveAs', + this.fb.control(action?.saveAs || MobileActionSaveAs.attributes, []) + ); + this.updateTargetEntityValidators(this.mobileActionTypeFormGroup.get('saveToEntity').value); + this.mobileActionTypeFormGroup.get('saveToEntity').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => + this.updateTargetEntityValidators(this.mobileActionTypeFormGroup.get('saveToEntity').value)); + this.mobileActionTypeFormGroup.get('targetEntity.type').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => + this.updateTargetEntityValidators(this.mobileActionTypeFormGroup.get('saveToEntity').value)); +``` + +- [ ] **Step 3: Add the two new cases** + +After the `getLocation` case's `break;` and before `case WidgetMobileActionType.deviceProvision:`, add: + +```typescript + case WidgetMobileActionType.startLiveLocation: + processLaunchResultFunction = action?.processLaunchResultFunction; + if (changed) { + const defaultLaunchResultFunction = getDefaultProcessLaunchResultFunction(targetType); + if (defaultLaunchResultFunction !== processLaunchResultFunction) { + processLaunchResultFunction = getDefaultProcessLaunchResultFunction(type); + } + } + this.addTargetEntityControls(action); + this.mobileActionTypeFormGroup.addControl( + 'accuracy', + this.fb.control(action?.accuracy || MobileActionLocationAccuracy.balanced, []) + ); + this.mobileActionTypeFormGroup.addControl( + 'distanceFilterMeters', + this.fb.control(action?.distanceFilterMeters, [Validators.min(0)]) + ); + this.mobileActionTypeFormGroup.addControl( + 'intervalSeconds', + this.fb.control(action?.intervalSeconds, [Validators.min(1)]) + ); + this.mobileActionTypeFormGroup.addControl( + 'maxDurationMinutes', + this.fb.control(action?.maxDurationMinutes, [Validators.min(1)]) + ); + this.mobileActionTypeFormGroup.addControl( + 'mirrorToAttributes', + this.fb.control(action?.mirrorToAttributes || false, []) + ); + this.mobileActionTypeFormGroup.addControl( + 'writeStatusAttributes', + this.fb.control(action?.writeStatusAttributes !== false, []) + ); + this.mobileActionTypeFormGroup.addControl( + 'processLaunchResultFunction', + this.fb.control(processLaunchResultFunction, []) + ); + this.updateTargetEntityValidators(true); + this.mobileActionTypeFormGroup.get('targetEntity.type').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => this.updateTargetEntityValidators(true)); + break; + case WidgetMobileActionType.stopLiveLocation: + processLaunchResultFunction = action?.processLaunchResultFunction; + if (changed) { + const defaultStopLaunchResultFunction = getDefaultProcessLaunchResultFunction(targetType); + if (defaultStopLaunchResultFunction !== processLaunchResultFunction) { + processLaunchResultFunction = getDefaultProcessLaunchResultFunction(type); + } + } + this.mobileActionTypeFormGroup.addControl( + 'processLaunchResultFunction', + this.fb.control(processLaunchResultFunction, []) + ); + break; +``` + +Note the `processLaunchResultFunction` local variable is already declared at the top of the switch (`let processLaunchResultFunction: TbFunction;`) — reuse it, don't redeclare. + +- [ ] **Step 4: Add the editor panels in `getActionConfigs`** + +In `getActionConfigs()`, add cases: + +```typescript + case this.mobileActionType.startLiveLocation: + case this.mobileActionType.stopLiveLocation: + this.actionConfig.push({ + title: 'widget-action.mobile.process-launch-result-function', + formControlName: 'processLaunchResultFunction', + functionName: 'processLaunchResult', + functionArgs: ['launched', '$event', 'widgetContext', 'entityId', 'entityName', 'additionalParams', 'entityLabel'], + helpId: 'widget/action/mobile_process_launch_result_fn' + }); + break; +``` + +- [ ] **Step 5: Restructure the HTML with a shared target-entity template** + +In `mobile-action-editor.component.html`, inside ``: + +1. Declare the shared template once (place it directly after the `` opening tag): + +```html + + +
+
{{ 'widget-action.mobile.target-entity-type' | translate }}
+ + + + {{ targetEntityTypeTranslations.get(type) | translate }} + + + +
+ @if (mobileActionTypeFormGroup.get('targetEntity.type').value === targetEntityType.entityAlias) { +
+
{{ 'widget-action.mobile.target-entity-alias-name' | translate }}*
+ + + + warning + + +
+ } + @if (mobileActionTypeFormGroup.get('targetEntity.type').value === targetEntityType.fromAttribute) { +
+
{{ 'widget-action.mobile.target-attribute-source' | translate }}
+ + + + {{ attributeSourceTranslations.get(source) | translate }} + + + +
+
+
{{ 'widget-action.mobile.target-attribute-key' | translate }}*
+ + + + warning + + +
+
+
{{ 'widget-action.mobile.target-default-entity-type' | translate }}
+ + +
+ } +
+
+
{{ 'widget-action.mobile.latitude-key' | translate }}
+ + + +
+
+
{{ 'widget-action.mobile.longitude-key' | translate }}
+ + + +
+
+``` + +2. In the existing phase 1b `getLocation` block, delete the inline `...` plus the latitude-key/longitude-key rows and replace them with `` (keep the saveToEntity toggle, save-as select, and includeMetadata toggle where they are; the includeMetadata row stays in the getLocation block, using the 1b `include-metadata` key). + +3. Add the `startLiveLocation` block after the `getLocation` block: + +```html + @if (mobileActionFormGroup.get('type').value === mobileActionType.startLiveLocation) { + +
+
{{ 'widget-action.mobile.accuracy' | translate }}
+ + + + {{ locationAccuracyTranslations.get(accuracy) | translate }} + + + +
+
+
{{ 'widget-action.mobile.distance-filter-meters' | translate }}
+ + + +
+
+
{{ 'widget-action.mobile.interval-seconds' | translate }}
+ + + +
+
+
{{ 'widget-action.mobile.max-duration-minutes' | translate }}
+ + + +
+
+ + {{ 'widget-action.mobile.include-metadata-live' | translate }} + +
+
+ + {{ 'widget-action.mobile.mirror-to-attributes' | translate }} + +
+
+ + {{ 'widget-action.mobile.write-status-attributes' | translate }} + +
+ } +``` + +(`stopLiveLocation` needs no type-specific block — its only config is the `processLaunchResultFunction` panel from `getActionConfigs` plus the common handlers.) + +Note: `*ngTemplateOutlet` requires `NgTemplateOutlet`; the module declaring this component imports `CommonModule`/`SharedModule`, which provides it — verify, and if the template outlet directive is missing at build time, add `NgTemplateOutlet` to the declaring module's imports. + +- [ ] **Step 6: Verify and commit** + +```bash +cd /home/artem/projects/thingsboard/ui-ngx && npx tsc --noEmit -p src/tsconfig.app.json +cd /home/artem/projects/thingsboard +git add ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/ +git commit -m "feat(mobile-actions): live location tracking configuration UI" +``` +Expected: only the 4 pre-existing photoswipe errors. + +--- + +### Task 8: ui-ngx — dispatch the new actions from the widget runtime + +**Files:** +- Modify: `ui-ngx/src/app/modules/home/components/widget/widget.component.ts` + +**Interfaces:** +- Consumes: Task 6's `MobileActionLocationAccuracy` and descriptor fields; phase 1b's `resolveMobileActionTargetEntity(mobileAction, currentEntityId): Observable` and `getCurrentAuthUser`; `defer` (already imported by the 1b fix commit — verify); the existing `handleMobileAction` args/result switch. +- Produces: the wire config object exactly as specified in Global Constraints. + +- [ ] **Step 1: Add the args cases** + +In `handleMobileAction`'s first `switch (type)` (args assembly), after the `scanQrCode`/`getLocation` case, add: + +```typescript + case WidgetMobileActionType.startLiveLocation: + argsObservable = defer(() => + this.resolveMobileActionTargetEntity(mobileAction, entityId)).pipe( + map((targetEntityId) => [this.buildLiveTrackingConfig(mobileAction, targetEntityId)]) + ); + break; + case WidgetMobileActionType.stopLiveLocation: + argsObservable = of([]); + break; +``` + +Confirm `defer` is in the `rxjs` import list (added by the 1b hardening commit); add if missing. Confirm the `argsObservable.subscribe({...})` has an `error:` handler routing to `handleWidgetMobileActionError` — it does for the map/phone-call functions; the new case reuses the same path. + +- [ ] **Step 2: Add the config builder** + +Add a private method next to `saveMobileActionLocation`: + +```typescript + private buildLiveTrackingConfig(mobileAction: WidgetMobileActionDescriptor, + targetEntityId: EntityId): object { + return { + target: { + entityType: targetEntityId.entityType, + id: targetEntityId.id + }, + latitudeKey: mobileAction.latitudeKey || 'latitude', + longitudeKey: mobileAction.longitudeKey || 'longitude', + includeMetadata: !!mobileAction.includeMetadata, + mirrorToAttributes: !!mobileAction.mirrorToAttributes, + accuracy: mobileAction.accuracy || MobileActionLocationAccuracy.balanced, + distanceFilterMeters: isDefinedAndNotNull(mobileAction.distanceFilterMeters) + ? mobileAction.distanceFilterMeters : null, + intervalSeconds: isDefinedAndNotNull(mobileAction.intervalSeconds) + ? mobileAction.intervalSeconds : null, + maxDurationMinutes: isDefinedAndNotNull(mobileAction.maxDurationMinutes) + ? mobileAction.maxDurationMinutes : null, + writeStatusAttributes: mobileAction.writeStatusAttributes !== false, + trackedBy: getCurrentAuthUser(this.store)?.sub || null + }; + } +``` + +Add `MobileActionLocationAccuracy` to the `@shared/models/widget.models` import list. + +- [ ] **Step 3: Route the launched result** + +In the result-handling switch (the `hasResult` branch), extend the launched-result case group: + +```typescript + case WidgetMobileActionType.mapDirection: + case WidgetMobileActionType.mapLocation: + case WidgetMobileActionType.makePhoneCall: + case WidgetMobileActionType.startLiveLocation: + case WidgetMobileActionType.stopLiveLocation: +``` + +(the body — `processLaunchResultFunction` invocation — is unchanged). + +- [ ] **Step 4: Verify and commit** + +```bash +cd /home/artem/projects/thingsboard/ui-ngx && npx tsc --noEmit -p src/tsconfig.app.json +cd /home/artem/projects/thingsboard +git add ui-ngx/src/app/modules/home/components/widget/widget.component.ts +git commit -m "feat(mobile-actions): dispatch live location tracking start/stop to the mobile app" +``` +Expected: only the 4 pre-existing photoswipe errors. + +--- + +### Task 9: End-to-end smoke test (manual, real device) + +**Files:** none (verification only). + +- [ ] **Step 1: Stack** — local TB CE backend + `yarn start` ui-ngx + Flutter debug build on the Android phone (endpoint = machine LAN IP). + +- [ ] **Step 2: Start action** — dashboard widget action → Mobile action → **Start live location tracking**: target Current user, accuracy Balanced, distance filter 10 m, status attributes on. Trigger from the phone: tracking bar appears on all main pages, Android notification visible, `gpsActive=true`/`gpsTrackedBy` attributes on your user, telemetry `latitude`/`longitude` flowing, `gpsLastUpdateTime` refreshing. + +- [ ] **Step 3: Bar controls** — Pause (bar shows paused, `gpsActive=false`, telemetry stops), Resume, Hide (collapses to pill; tracking continues), tap bar → session screen shows live counters. + +- [ ] **Step 4: Background** — lock the screen 10 min while moving; telemetry keeps flowing (phase 1a already proved the plumbing; this validates it wired through the service). + +- [ ] **Step 5: Stop paths** — dashboard **Stop live location tracking** action stops the session (bar disappears, `gpsActive=false`); repeat stop → dashboard's empty-result handler fires. Start with max duration 2 min → auto-stops. + +- [ ] **Step 6: Replace semantics** — start tracking targeting entity A, then start again targeting entity B → A gets `gpsActive=false`, B `true`, single session. + +- [ ] **Step 7: Old-app compat** — (optional) an app build without this feature receiving `startLiveLocation` → explicit "unknown action" error dialog on the dashboard. From 1b4681fef9ee5e891b8f813b2b70c929e19c009b Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 22 Jul 2026 12:33:48 +0300 Subject: [PATCH 09/40] feat(location): expose altitude, speed and heading on GeoPosition --- .../services/location/location_service.dart | 3 ++ .../services/location/model/geo_position.dart | 3 ++ .../location/model/geo_position.freezed.dart | 49 +++++++++++-------- 3 files changed, 35 insertions(+), 20 deletions(-) diff --git a/lib/utils/services/location/location_service.dart b/lib/utils/services/location/location_service.dart index 2f32670e..150ca83a 100644 --- a/lib/utils/services/location/location_service.dart +++ b/lib/utils/services/location/location_service.dart @@ -131,5 +131,8 @@ class LocationService implements ILocationService { longitude: p.longitude, accuracy: p.accuracy, timestamp: p.timestamp, + altitude: p.altitude, + speed: p.speed, + heading: p.heading, ); } diff --git a/lib/utils/services/location/model/geo_position.dart b/lib/utils/services/location/model/geo_position.dart index 645c8cc3..afdfaafa 100644 --- a/lib/utils/services/location/model/geo_position.dart +++ b/lib/utils/services/location/model/geo_position.dart @@ -11,5 +11,8 @@ abstract class GeoPosition with _$GeoPosition { required double longitude, required double accuracy, DateTime? timestamp, + double? altitude, + double? speed, + double? heading, }) = _GeoPosition; } diff --git a/lib/utils/services/location/model/geo_position.freezed.dart b/lib/utils/services/location/model/geo_position.freezed.dart index 4a5aefc3..109c1e12 100644 --- a/lib/utils/services/location/model/geo_position.freezed.dart +++ b/lib/utils/services/location/model/geo_position.freezed.dart @@ -14,7 +14,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$GeoPosition { - double get latitude; double get longitude; double get accuracy; DateTime? get timestamp; + double get latitude; double get longitude; double get accuracy; DateTime? get timestamp; double? get altitude; double? get speed; double? get heading; /// Create a copy of GeoPosition /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -25,16 +25,16 @@ $GeoPositionCopyWith get copyWith => _$GeoPositionCopyWithImpl Object.hash(runtimeType,latitude,longitude,accuracy,timestamp); +int get hashCode => Object.hash(runtimeType,latitude,longitude,accuracy,timestamp,altitude,speed,heading); @override String toString() { - return 'GeoPosition(latitude: $latitude, longitude: $longitude, accuracy: $accuracy, timestamp: $timestamp)'; + return 'GeoPosition(latitude: $latitude, longitude: $longitude, accuracy: $accuracy, timestamp: $timestamp, altitude: $altitude, speed: $speed, heading: $heading)'; } @@ -45,7 +45,7 @@ abstract mixin class $GeoPositionCopyWith<$Res> { factory $GeoPositionCopyWith(GeoPosition value, $Res Function(GeoPosition) _then) = _$GeoPositionCopyWithImpl; @useResult $Res call({ - double latitude, double longitude, double accuracy, DateTime? timestamp + double latitude, double longitude, double accuracy, DateTime? timestamp, double? altitude, double? speed, double? heading }); @@ -62,13 +62,16 @@ class _$GeoPositionCopyWithImpl<$Res> /// Create a copy of GeoPosition /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? latitude = null,Object? longitude = null,Object? accuracy = null,Object? timestamp = freezed,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? latitude = null,Object? longitude = null,Object? accuracy = null,Object? timestamp = freezed,Object? altitude = freezed,Object? speed = freezed,Object? heading = freezed,}) { return _then(_self.copyWith( latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable as double,longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable as double,accuracy: null == accuracy ? _self.accuracy : accuracy // ignore: cast_nullable_to_non_nullable as double,timestamp: freezed == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable -as DateTime?, +as DateTime?,altitude: freezed == altitude ? _self.altitude : altitude // ignore: cast_nullable_to_non_nullable +as double?,speed: freezed == speed ? _self.speed : speed // ignore: cast_nullable_to_non_nullable +as double?,heading: freezed == heading ? _self.heading : heading // ignore: cast_nullable_to_non_nullable +as double?, )); } @@ -153,10 +156,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( double latitude, double longitude, double accuracy, DateTime? timestamp)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( double latitude, double longitude, double accuracy, DateTime? timestamp, double? altitude, double? speed, double? heading)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _GeoPosition() when $default != null: -return $default(_that.latitude,_that.longitude,_that.accuracy,_that.timestamp);case _: +return $default(_that.latitude,_that.longitude,_that.accuracy,_that.timestamp,_that.altitude,_that.speed,_that.heading);case _: return orElse(); } @@ -174,10 +177,10 @@ return $default(_that.latitude,_that.longitude,_that.accuracy,_that.timestamp);c /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( double latitude, double longitude, double accuracy, DateTime? timestamp) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( double latitude, double longitude, double accuracy, DateTime? timestamp, double? altitude, double? speed, double? heading) $default,) {final _that = this; switch (_that) { case _GeoPosition(): -return $default(_that.latitude,_that.longitude,_that.accuracy,_that.timestamp);case _: +return $default(_that.latitude,_that.longitude,_that.accuracy,_that.timestamp,_that.altitude,_that.speed,_that.heading);case _: throw StateError('Unexpected subclass'); } @@ -194,10 +197,10 @@ return $default(_that.latitude,_that.longitude,_that.accuracy,_that.timestamp);c /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( double latitude, double longitude, double accuracy, DateTime? timestamp)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( double latitude, double longitude, double accuracy, DateTime? timestamp, double? altitude, double? speed, double? heading)? $default,) {final _that = this; switch (_that) { case _GeoPosition() when $default != null: -return $default(_that.latitude,_that.longitude,_that.accuracy,_that.timestamp);case _: +return $default(_that.latitude,_that.longitude,_that.accuracy,_that.timestamp,_that.altitude,_that.speed,_that.heading);case _: return null; } @@ -209,13 +212,16 @@ return $default(_that.latitude,_that.longitude,_that.accuracy,_that.timestamp);c class _GeoPosition implements GeoPosition { - const _GeoPosition({required this.latitude, required this.longitude, required this.accuracy, this.timestamp}); + const _GeoPosition({required this.latitude, required this.longitude, required this.accuracy, this.timestamp, this.altitude, this.speed, this.heading}); @override final double latitude; @override final double longitude; @override final double accuracy; @override final DateTime? timestamp; +@override final double? altitude; +@override final double? speed; +@override final double? heading; /// Create a copy of GeoPosition /// with the given fields replaced by the non-null parameter values. @@ -227,16 +233,16 @@ _$GeoPositionCopyWith<_GeoPosition> get copyWith => __$GeoPositionCopyWithImpl<_ @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _GeoPosition&&(identical(other.latitude, latitude) || other.latitude == latitude)&&(identical(other.longitude, longitude) || other.longitude == longitude)&&(identical(other.accuracy, accuracy) || other.accuracy == accuracy)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _GeoPosition&&(identical(other.latitude, latitude) || other.latitude == latitude)&&(identical(other.longitude, longitude) || other.longitude == longitude)&&(identical(other.accuracy, accuracy) || other.accuracy == accuracy)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)&&(identical(other.altitude, altitude) || other.altitude == altitude)&&(identical(other.speed, speed) || other.speed == speed)&&(identical(other.heading, heading) || other.heading == heading)); } @override -int get hashCode => Object.hash(runtimeType,latitude,longitude,accuracy,timestamp); +int get hashCode => Object.hash(runtimeType,latitude,longitude,accuracy,timestamp,altitude,speed,heading); @override String toString() { - return 'GeoPosition(latitude: $latitude, longitude: $longitude, accuracy: $accuracy, timestamp: $timestamp)'; + return 'GeoPosition(latitude: $latitude, longitude: $longitude, accuracy: $accuracy, timestamp: $timestamp, altitude: $altitude, speed: $speed, heading: $heading)'; } @@ -247,7 +253,7 @@ abstract mixin class _$GeoPositionCopyWith<$Res> implements $GeoPositionCopyWith factory _$GeoPositionCopyWith(_GeoPosition value, $Res Function(_GeoPosition) _then) = __$GeoPositionCopyWithImpl; @override @useResult $Res call({ - double latitude, double longitude, double accuracy, DateTime? timestamp + double latitude, double longitude, double accuracy, DateTime? timestamp, double? altitude, double? speed, double? heading }); @@ -264,13 +270,16 @@ class __$GeoPositionCopyWithImpl<$Res> /// Create a copy of GeoPosition /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? latitude = null,Object? longitude = null,Object? accuracy = null,Object? timestamp = freezed,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? latitude = null,Object? longitude = null,Object? accuracy = null,Object? timestamp = freezed,Object? altitude = freezed,Object? speed = freezed,Object? heading = freezed,}) { return _then(_GeoPosition( latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable as double,longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable as double,accuracy: null == accuracy ? _self.accuracy : accuracy // ignore: cast_nullable_to_non_nullable as double,timestamp: freezed == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable -as DateTime?, +as DateTime?,altitude: freezed == altitude ? _self.altitude : altitude // ignore: cast_nullable_to_non_nullable +as double?,speed: freezed == speed ? _self.speed : speed // ignore: cast_nullable_to_non_nullable +as double?,heading: freezed == heading ? _self.heading : heading // ignore: cast_nullable_to_non_nullable +as double?, )); } From edcc4957fab238b32cea4f0429c26aa37d999373 Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 22 Jul 2026 12:35:00 +0300 Subject: [PATCH 10/40] feat(location): add live tracking wire config model --- .../model/live_tracking_config.dart | 79 +++++++++++++++++++ .../live_tracking_config_test.dart | 76 ++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 lib/utils/services/live_location_tracking/model/live_tracking_config.dart create mode 100644 test/utils/services/live_location_tracking/live_tracking_config_test.dart diff --git a/lib/utils/services/live_location_tracking/model/live_tracking_config.dart b/lib/utils/services/live_location_tracking/model/live_tracking_config.dart new file mode 100644 index 00000000..66f01048 --- /dev/null +++ b/lib/utils/services/live_location_tracking/model/live_tracking_config.dart @@ -0,0 +1,79 @@ +import 'package:thingsboard_app/utils/services/location/model/location_stream_settings.dart'; + +class LiveTrackingTarget { + const LiveTrackingTarget({required this.entityType, required this.id}); + + factory LiveTrackingTarget.fromJson(Map json) { + final entityType = json['entityType']; + final id = json['id']; + if (entityType is! String || id is! String) { + throw const FormatException( + 'Live tracking target must contain entityType and id', + ); + } + return LiveTrackingTarget(entityType: entityType, id: id); + } + + final String entityType; + final String id; +} + +/// Fully-resolved live tracking session config received from the dashboard +/// (the web side resolves aliases/attributes to a concrete target entity +/// before sending — see the phase 1c wire protocol in the plan doc). +class LiveTrackingConfig { + const LiveTrackingConfig({ + required this.target, + this.latitudeKey = 'latitude', + this.longitudeKey = 'longitude', + this.includeMetadata = false, + this.mirrorToAttributes = false, + this.accuracy = LocationAccuracyLevel.balanced, + this.distanceFilterMeters, + this.intervalSeconds, + this.maxDurationMinutes, + this.writeStatusAttributes = true, + this.trackedBy, + }); + + factory LiveTrackingConfig.fromJson(Map json) { + final targetJson = json['target']; + if (targetJson is! Map) { + throw const FormatException('Live tracking config is missing target'); + } + return LiveTrackingConfig( + target: LiveTrackingTarget.fromJson( + Map.from(targetJson), + ), + latitudeKey: json['latitudeKey'] as String? ?? 'latitude', + longitudeKey: json['longitudeKey'] as String? ?? 'longitude', + includeMetadata: json['includeMetadata'] as bool? ?? false, + mirrorToAttributes: json['mirrorToAttributes'] as bool? ?? false, + accuracy: _accuracyFromString(json['accuracy'] as String?), + distanceFilterMeters: (json['distanceFilterMeters'] as num?)?.toInt(), + intervalSeconds: (json['intervalSeconds'] as num?)?.toInt(), + maxDurationMinutes: (json['maxDurationMinutes'] as num?)?.toInt(), + writeStatusAttributes: json['writeStatusAttributes'] as bool? ?? true, + trackedBy: json['trackedBy'] as String?, + ); + } + + final LiveTrackingTarget target; + final String latitudeKey; + final String longitudeKey; + final bool includeMetadata; + final bool mirrorToAttributes; + final LocationAccuracyLevel accuracy; + final int? distanceFilterMeters; + final int? intervalSeconds; + final int? maxDurationMinutes; + final bool writeStatusAttributes; + final String? trackedBy; + + static LocationAccuracyLevel _accuracyFromString(String? value) => + switch (value) { + 'HIGH' => LocationAccuracyLevel.high, + 'LOW' => LocationAccuracyLevel.low, + _ => LocationAccuracyLevel.balanced, + }; +} diff --git a/test/utils/services/live_location_tracking/live_tracking_config_test.dart b/test/utils/services/live_location_tracking/live_tracking_config_test.dart new file mode 100644 index 00000000..5e72838e --- /dev/null +++ b/test/utils/services/live_location_tracking/live_tracking_config_test.dart @@ -0,0 +1,76 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_stream_settings.dart'; + +void main() { + test('parses a full config', () { + final config = LiveTrackingConfig.fromJson(const { + 'target': {'entityType': 'DEVICE', 'id': 'abc-123'}, + 'latitudeKey': 'lat', + 'longitudeKey': 'lng', + 'includeMetadata': true, + 'mirrorToAttributes': true, + 'accuracy': 'HIGH', + 'distanceFilterMeters': 25, + 'intervalSeconds': 60, + 'maxDurationMinutes': 120, + 'writeStatusAttributes': false, + 'trackedBy': 'user@example.com', + }); + + expect(config.target.entityType, 'DEVICE'); + expect(config.target.id, 'abc-123'); + expect(config.latitudeKey, 'lat'); + expect(config.longitudeKey, 'lng'); + expect(config.includeMetadata, true); + expect(config.mirrorToAttributes, true); + expect(config.accuracy, LocationAccuracyLevel.high); + expect(config.distanceFilterMeters, 25); + expect(config.intervalSeconds, 60); + expect(config.maxDurationMinutes, 120); + expect(config.writeStatusAttributes, false); + expect(config.trackedBy, 'user@example.com'); + }); + + test('applies defaults for a minimal config', () { + final config = LiveTrackingConfig.fromJson(const { + 'target': {'entityType': 'USER', 'id': 'u-1'}, + }); + + expect(config.latitudeKey, 'latitude'); + expect(config.longitudeKey, 'longitude'); + expect(config.includeMetadata, false); + expect(config.mirrorToAttributes, false); + expect(config.accuracy, LocationAccuracyLevel.balanced); + expect(config.distanceFilterMeters, isNull); + expect(config.intervalSeconds, isNull); + expect(config.maxDurationMinutes, isNull); + expect(config.writeStatusAttributes, true); + expect(config.trackedBy, isNull); + }); + + test('unknown accuracy falls back to balanced', () { + final config = LiveTrackingConfig.fromJson(const { + 'target': {'entityType': 'USER', 'id': 'u-1'}, + 'accuracy': 'ULTRA', + }); + + expect(config.accuracy, LocationAccuracyLevel.balanced); + }); + + test('missing target throws FormatException', () { + expect( + () => LiveTrackingConfig.fromJson(const {'latitudeKey': 'lat'}), + throwsFormatException, + ); + }); + + test('malformed target throws FormatException', () { + expect( + () => LiveTrackingConfig.fromJson(const { + 'target': {'entityType': 'DEVICE'}, + }), + throwsFormatException, + ); + }); +} From e10752b8bd30357077a0895c2ed7b0bc71faeba2 Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 22 Jul 2026 12:46:51 +0300 Subject: [PATCH 11/40] feat(location): add live location tracking session service --- lib/locator.dart | 22 +- .../i_live_location_tracking_service.dart | 23 ++ .../i_live_tracking_remote.dart | 19 ++ .../live_location_tracking_service.dart | 214 ++++++++++++ .../live_tracking_remote.dart | 41 +++ .../model/live_tracking_session.dart | 21 ++ .../model/live_tracking_session.freezed.dart | 316 ++++++++++++++++++ pubspec.lock | 2 +- pubspec.yaml | 1 + .../live_location_tracking_service_test.dart | 252 ++++++++++++++ 10 files changed, 908 insertions(+), 3 deletions(-) create mode 100644 lib/utils/services/live_location_tracking/i_live_location_tracking_service.dart create mode 100644 lib/utils/services/live_location_tracking/i_live_tracking_remote.dart create mode 100644 lib/utils/services/live_location_tracking/live_location_tracking_service.dart create mode 100644 lib/utils/services/live_location_tracking/live_tracking_remote.dart create mode 100644 lib/utils/services/live_location_tracking/model/live_tracking_session.dart create mode 100644 lib/utils/services/live_location_tracking/model/live_tracking_session.freezed.dart create mode 100644 test/utils/services/live_location_tracking/live_location_tracking_service_test.dart diff --git a/lib/locator.dart b/lib/locator.dart index 38949385..0a38a1ec 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -14,6 +14,10 @@ import 'package:thingsboard_app/utils/services/firebase/firebase_service.dart'; import 'package:thingsboard_app/utils/services/firebase/i_firebase_service.dart'; import 'package:thingsboard_app/utils/services/layouts/i_layout_service.dart'; import 'package:thingsboard_app/utils/services/layouts/layout_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_remote.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_remote.dart'; import 'package:thingsboard_app/utils/services/loading_service/i_loading_service.dart'; import 'package:thingsboard_app/utils/services/loading_service/loading_service.dart'; import 'package:thingsboard_app/utils/services/local_database/i_local_database_service.dart'; @@ -53,12 +57,26 @@ Future setUpRootDependencies() async { () => EndpointService(databaseService: getIt()), ) ..registerLazySingleton(() => OverlayService()) - ..registerLazySingleton(() => TbImageGalleryService()) - ..registerLazySingleton(() => ThingsboardAppRouter(overlayService: getIt())) + ..registerLazySingleton( + () => TbImageGalleryService(), + ) + ..registerLazySingleton( + () => ThingsboardAppRouter(overlayService: getIt()), + ) ..registerLazySingleton(() => deviceInfoService) ..registerLazySingleton( () => LocationService(logger: getIt()), ) + ..registerLazySingleton( + () => LiveTrackingRemote(clientService: getIt()), + ) + ..registerLazySingleton( + () => LiveLocationTrackingService( + locationService: getIt(), + remote: getIt(), + logger: getIt(), + ), + ) // ..registerLazySingleton(() => TbContext()) ..registerSingletonAsync(() async { final client = TbClientService(); diff --git a/lib/utils/services/live_location_tracking/i_live_location_tracking_service.dart b/lib/utils/services/live_location_tracking/i_live_location_tracking_service.dart new file mode 100644 index 00000000..f44bd9b3 --- /dev/null +++ b/lib/utils/services/live_location_tracking/i_live_location_tracking_service.dart @@ -0,0 +1,23 @@ +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; + +/// App-wide owner of at most one live GPS tracking session. Runs the +/// location stream, saves telemetry/attributes per fix, and exposes session +/// state for the tracking bar / session screen. +abstract interface class ILiveLocationTrackingService { + LiveTrackingSession? get session; + + /// Emits on every session change; emits `null` when tracking stops. + Stream get sessionStream; + + /// Starts a session, replacing any active one. + Future start(LiveTrackingConfig config); + + Future stop(); + + /// Suspends position updates without discarding the session; writes + /// `gpsActive=false` so the platform sees data flow honestly stopped. + Future pause(); + + Future resume(); +} diff --git a/lib/utils/services/live_location_tracking/i_live_tracking_remote.dart b/lib/utils/services/live_location_tracking/i_live_tracking_remote.dart new file mode 100644 index 00000000..0dd833b2 --- /dev/null +++ b/lib/utils/services/live_location_tracking/i_live_tracking_remote.dart @@ -0,0 +1,19 @@ +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; + +/// Thin REST boundary for live tracking saves. Kept as an interface so the +/// tracking service is unit-testable and the CE/PE client difference stays +/// behind one seam. +abstract interface class ILiveTrackingRemote { + /// Saves one timeseries sample: `{ts, values}` on the target entity. + Future saveTelemetry( + LiveTrackingTarget target, + int ts, + Map values, + ); + + /// Saves SERVER_SCOPE attributes on the target entity. + Future saveAttributes( + LiveTrackingTarget target, + Map attributes, + ); +} diff --git a/lib/utils/services/live_location_tracking/live_location_tracking_service.dart b/lib/utils/services/live_location_tracking/live_location_tracking_service.dart new file mode 100644 index 00000000..621e7ce3 --- /dev/null +++ b/lib/utils/services/live_location_tracking/live_location_tracking_service.dart @@ -0,0 +1,214 @@ +import 'dart:async'; + +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_remote.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; +import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; +import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_stream_settings.dart'; + +class LiveLocationTrackingService implements ILiveLocationTrackingService { + LiveLocationTrackingService({ + required ILocationService locationService, + required ILiveTrackingRemote remote, + required TbLogger logger, + // Android notification strings are OS-level, set once at construction; + // English defaults are acceptable for v1 (the locator can later pass + // localized strings without touching this class). + this.backgroundConfig = const BackgroundTrackingConfig( + notificationTitle: 'ThingsBoard', + notificationText: 'Live location tracking is active', + ), + }) : _locationService = locationService, + _remote = remote, + _log = logger; + + final ILocationService _locationService; + final ILiveTrackingRemote _remote; + final TbLogger _log; + final BackgroundTrackingConfig backgroundConfig; + + final _sessionController = StreamController.broadcast(); + LiveTrackingSession? _session; + StreamSubscription? _subscription; + Timer? _maxDurationTimer; + + @override + LiveTrackingSession? get session => _session; + + @override + Stream get sessionStream => _sessionController.stream; + + @override + Future start(LiveTrackingConfig config) async { + await stop(); + _setSession( + LiveTrackingSession( + config: config, + status: LiveTrackingStatus.tracking, + startedAt: DateTime.now(), + ), + ); + await _writeStatusAttributes({ + 'gpsActive': true, + if (config.trackedBy != null) 'gpsTrackedBy': config.trackedBy, + }); + _subscribe(config); + final maxDuration = config.maxDurationMinutes; + if (maxDuration != null) { + _maxDurationTimer = Timer(Duration(minutes: maxDuration), stop); + } + } + + @override + Future stop() async { + _maxDurationTimer?.cancel(); + _maxDurationTimer = null; + _cancelSubscription(); + if (_session != null) { + await _writeStatusAttributes({'gpsActive': false}); + _setSession(null); + } + } + + @override + Future pause() async { + final current = _session; + if (current == null || current.status != LiveTrackingStatus.tracking) { + return; + } + _cancelSubscription(); + _setSession(current.copyWith(status: LiveTrackingStatus.paused)); + await _writeStatusAttributes({'gpsActive': false}); + } + + @override + Future resume() async { + final current = _session; + if (current == null || current.status != LiveTrackingStatus.paused) { + return; + } + _setSession( + current.copyWith(status: LiveTrackingStatus.tracking, lastError: null), + ); + await _writeStatusAttributes({'gpsActive': true}); + _subscribe(current.config); + } + + void _subscribe(LiveTrackingConfig config) { + _subscription = _locationService + .positionStream( + settings: LocationStreamSettings( + accuracy: config.accuracy, + distanceFilterMeters: config.distanceFilterMeters ?? 0, + interval: + config.intervalSeconds != null + ? Duration(seconds: config.intervalSeconds!) + : null, + background: backgroundConfig, + ), + ) + .listen(_onFix); + } + + Future _onFix(LocationFix fix) async { + final current = _session; + if (current == null) { + return; + } + switch (fix) { + case LocationSuccess(:final position): + _setSession( + current.copyWith(fixCount: current.fixCount + 1, lastFix: position), + ); + await _saveFix(current.config, position); + case LocationServicesDisabled(): + await _pauseWithError('Location services are disabled.'); + case LocationPermissionDenied(): + await _pauseWithError('Location permission denied.'); + case LocationPermissionDeniedForever(): + await _pauseWithError('Location permission permanently denied.'); + case LocationFixError(:final message): + _setSession(_session?.copyWith(lastError: message)); + } + } + + Future _saveFix(LiveTrackingConfig config, GeoPosition position) async { + final values = { + config.latitudeKey: position.latitude, + config.longitudeKey: position.longitude, + if (config.includeMetadata) ...{ + 'gpsAccuracy': position.accuracy, + if (position.altitude != null) 'gpsAltitude': position.altitude, + if (position.speed != null) 'gpsSpeed': position.speed, + if (position.heading != null) 'gpsHeading': position.heading, + }, + }; + final ts = (position.timestamp ?? DateTime.now()).millisecondsSinceEpoch; + try { + await _remote.saveTelemetry(config.target, ts, values); + final attributes = { + if (config.mirrorToAttributes) ...values, + if (config.writeStatusAttributes) 'gpsLastUpdateTime': ts, + }; + if (attributes.isNotEmpty) { + await _remote.saveAttributes(config.target, attributes); + } + final current = _session; + if (current != null) { + _setSession(current.copyWith(savedCount: current.savedCount + 1)); + } + } catch (e, s) { + _log.error('LiveLocationTrackingService: save failed', e, s); + final current = _session; + if (current != null) { + _setSession( + current.copyWith( + saveErrorCount: current.saveErrorCount + 1, + lastError: e.toString(), + ), + ); + } + } + } + + Future _pauseWithError(String message) async { + final current = _session; + if (current == null) { + return; + } + _cancelSubscription(); + _setSession( + current.copyWith(status: LiveTrackingStatus.paused, lastError: message), + ); + await _writeStatusAttributes({'gpsActive': false}); + } + + /// Fire-and-forget teardown: [StreamSubscription.cancel] detaches the + /// listener synchronously, so awaiting its completion would only gate on the + /// plugin's native teardown — which we don't need to block session state on. + void _cancelSubscription() { + unawaited(_subscription?.cancel()); + _subscription = null; + } + + Future _writeStatusAttributes(Map attributes) async { + final config = _session?.config; + if (config == null || !config.writeStatusAttributes) { + return; + } + try { + await _remote.saveAttributes(config.target, attributes); + } catch (e, s) { + _log.error('LiveLocationTrackingService: status attributes failed', e, s); + } + } + + void _setSession(LiveTrackingSession? session) { + _session = session; + _sessionController.add(session); + } +} diff --git a/lib/utils/services/live_location_tracking/live_tracking_remote.dart b/lib/utils/services/live_location_tracking/live_tracking_remote.dart new file mode 100644 index 00000000..23dad5f1 --- /dev/null +++ b/lib/utils/services/live_location_tracking/live_tracking_remote.dart @@ -0,0 +1,41 @@ +import 'dart:convert'; + +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_remote.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; + +class LiveTrackingRemote implements ILiveTrackingRemote { + LiveTrackingRemote({required ITbClientService clientService}) + : _clientService = clientService; + + final ITbClientService _clientService; + + @override + Future saveTelemetry( + LiveTrackingTarget target, + int ts, + Map values, + ) async { + await _clientService.client.getTelemetryControllerApi().saveEntityTelemetry( + entityType: target.entityType, + entityId: target.id, + scope: 'ANY', + body: jsonEncode({'ts': ts, 'values': values}), + ); + } + + @override + Future saveAttributes( + LiveTrackingTarget target, + Map attributes, + ) async { + await _clientService.client + .getTelemetryControllerApi() + .saveEntityAttributesV2( + entityType: target.entityType, + entityId: target.id, + scope: 'SERVER_SCOPE', + body: jsonEncode(attributes), + ); + } +} diff --git a/lib/utils/services/live_location_tracking/model/live_tracking_session.dart b/lib/utils/services/live_location_tracking/model/live_tracking_session.dart new file mode 100644 index 00000000..48c111df --- /dev/null +++ b/lib/utils/services/live_location_tracking/model/live_tracking_session.dart @@ -0,0 +1,21 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; + +part 'live_tracking_session.freezed.dart'; + +enum LiveTrackingStatus { tracking, paused } + +@freezed +abstract class LiveTrackingSession with _$LiveTrackingSession { + const factory LiveTrackingSession({ + required LiveTrackingConfig config, + required LiveTrackingStatus status, + required DateTime startedAt, + @Default(0) int fixCount, + @Default(0) int savedCount, + @Default(0) int saveErrorCount, + GeoPosition? lastFix, + String? lastError, + }) = _LiveTrackingSession; +} diff --git a/lib/utils/services/live_location_tracking/model/live_tracking_session.freezed.dart b/lib/utils/services/live_location_tracking/model/live_tracking_session.freezed.dart new file mode 100644 index 00000000..2a37132f --- /dev/null +++ b/lib/utils/services/live_location_tracking/model/live_tracking_session.freezed.dart @@ -0,0 +1,316 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'live_tracking_session.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$LiveTrackingSession { + + LiveTrackingConfig get config; LiveTrackingStatus get status; DateTime get startedAt; int get fixCount; int get savedCount; int get saveErrorCount; GeoPosition? get lastFix; String? get lastError; +/// Create a copy of LiveTrackingSession +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LiveTrackingSessionCopyWith get copyWith => _$LiveTrackingSessionCopyWithImpl(this as LiveTrackingSession, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LiveTrackingSession&&(identical(other.config, config) || other.config == config)&&(identical(other.status, status) || other.status == status)&&(identical(other.startedAt, startedAt) || other.startedAt == startedAt)&&(identical(other.fixCount, fixCount) || other.fixCount == fixCount)&&(identical(other.savedCount, savedCount) || other.savedCount == savedCount)&&(identical(other.saveErrorCount, saveErrorCount) || other.saveErrorCount == saveErrorCount)&&(identical(other.lastFix, lastFix) || other.lastFix == lastFix)&&(identical(other.lastError, lastError) || other.lastError == lastError)); +} + + +@override +int get hashCode => Object.hash(runtimeType,config,status,startedAt,fixCount,savedCount,saveErrorCount,lastFix,lastError); + +@override +String toString() { + return 'LiveTrackingSession(config: $config, status: $status, startedAt: $startedAt, fixCount: $fixCount, savedCount: $savedCount, saveErrorCount: $saveErrorCount, lastFix: $lastFix, lastError: $lastError)'; +} + + +} + +/// @nodoc +abstract mixin class $LiveTrackingSessionCopyWith<$Res> { + factory $LiveTrackingSessionCopyWith(LiveTrackingSession value, $Res Function(LiveTrackingSession) _then) = _$LiveTrackingSessionCopyWithImpl; +@useResult +$Res call({ + LiveTrackingConfig config, LiveTrackingStatus status, DateTime startedAt, int fixCount, int savedCount, int saveErrorCount, GeoPosition? lastFix, String? lastError +}); + + +$GeoPositionCopyWith<$Res>? get lastFix; + +} +/// @nodoc +class _$LiveTrackingSessionCopyWithImpl<$Res> + implements $LiveTrackingSessionCopyWith<$Res> { + _$LiveTrackingSessionCopyWithImpl(this._self, this._then); + + final LiveTrackingSession _self; + final $Res Function(LiveTrackingSession) _then; + +/// Create a copy of LiveTrackingSession +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? config = null,Object? status = null,Object? startedAt = null,Object? fixCount = null,Object? savedCount = null,Object? saveErrorCount = null,Object? lastFix = freezed,Object? lastError = freezed,}) { + return _then(_self.copyWith( +config: null == config ? _self.config : config // ignore: cast_nullable_to_non_nullable +as LiveTrackingConfig,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as LiveTrackingStatus,startedAt: null == startedAt ? _self.startedAt : startedAt // ignore: cast_nullable_to_non_nullable +as DateTime,fixCount: null == fixCount ? _self.fixCount : fixCount // ignore: cast_nullable_to_non_nullable +as int,savedCount: null == savedCount ? _self.savedCount : savedCount // ignore: cast_nullable_to_non_nullable +as int,saveErrorCount: null == saveErrorCount ? _self.saveErrorCount : saveErrorCount // ignore: cast_nullable_to_non_nullable +as int,lastFix: freezed == lastFix ? _self.lastFix : lastFix // ignore: cast_nullable_to_non_nullable +as GeoPosition?,lastError: freezed == lastError ? _self.lastError : lastError // ignore: cast_nullable_to_non_nullable +as String?, + )); +} +/// Create a copy of LiveTrackingSession +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$GeoPositionCopyWith<$Res>? get lastFix { + if (_self.lastFix == null) { + return null; + } + + return $GeoPositionCopyWith<$Res>(_self.lastFix!, (value) { + return _then(_self.copyWith(lastFix: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [LiveTrackingSession]. +extension LiveTrackingSessionPatterns on LiveTrackingSession { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LiveTrackingSession value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LiveTrackingSession() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LiveTrackingSession value) $default,){ +final _that = this; +switch (_that) { +case _LiveTrackingSession(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LiveTrackingSession value)? $default,){ +final _that = this; +switch (_that) { +case _LiveTrackingSession() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( LiveTrackingConfig config, LiveTrackingStatus status, DateTime startedAt, int fixCount, int savedCount, int saveErrorCount, GeoPosition? lastFix, String? lastError)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LiveTrackingSession() when $default != null: +return $default(_that.config,_that.status,_that.startedAt,_that.fixCount,_that.savedCount,_that.saveErrorCount,_that.lastFix,_that.lastError);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( LiveTrackingConfig config, LiveTrackingStatus status, DateTime startedAt, int fixCount, int savedCount, int saveErrorCount, GeoPosition? lastFix, String? lastError) $default,) {final _that = this; +switch (_that) { +case _LiveTrackingSession(): +return $default(_that.config,_that.status,_that.startedAt,_that.fixCount,_that.savedCount,_that.saveErrorCount,_that.lastFix,_that.lastError);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( LiveTrackingConfig config, LiveTrackingStatus status, DateTime startedAt, int fixCount, int savedCount, int saveErrorCount, GeoPosition? lastFix, String? lastError)? $default,) {final _that = this; +switch (_that) { +case _LiveTrackingSession() when $default != null: +return $default(_that.config,_that.status,_that.startedAt,_that.fixCount,_that.savedCount,_that.saveErrorCount,_that.lastFix,_that.lastError);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _LiveTrackingSession implements LiveTrackingSession { + const _LiveTrackingSession({required this.config, required this.status, required this.startedAt, this.fixCount = 0, this.savedCount = 0, this.saveErrorCount = 0, this.lastFix, this.lastError}); + + +@override final LiveTrackingConfig config; +@override final LiveTrackingStatus status; +@override final DateTime startedAt; +@override@JsonKey() final int fixCount; +@override@JsonKey() final int savedCount; +@override@JsonKey() final int saveErrorCount; +@override final GeoPosition? lastFix; +@override final String? lastError; + +/// Create a copy of LiveTrackingSession +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LiveTrackingSessionCopyWith<_LiveTrackingSession> get copyWith => __$LiveTrackingSessionCopyWithImpl<_LiveTrackingSession>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LiveTrackingSession&&(identical(other.config, config) || other.config == config)&&(identical(other.status, status) || other.status == status)&&(identical(other.startedAt, startedAt) || other.startedAt == startedAt)&&(identical(other.fixCount, fixCount) || other.fixCount == fixCount)&&(identical(other.savedCount, savedCount) || other.savedCount == savedCount)&&(identical(other.saveErrorCount, saveErrorCount) || other.saveErrorCount == saveErrorCount)&&(identical(other.lastFix, lastFix) || other.lastFix == lastFix)&&(identical(other.lastError, lastError) || other.lastError == lastError)); +} + + +@override +int get hashCode => Object.hash(runtimeType,config,status,startedAt,fixCount,savedCount,saveErrorCount,lastFix,lastError); + +@override +String toString() { + return 'LiveTrackingSession(config: $config, status: $status, startedAt: $startedAt, fixCount: $fixCount, savedCount: $savedCount, saveErrorCount: $saveErrorCount, lastFix: $lastFix, lastError: $lastError)'; +} + + +} + +/// @nodoc +abstract mixin class _$LiveTrackingSessionCopyWith<$Res> implements $LiveTrackingSessionCopyWith<$Res> { + factory _$LiveTrackingSessionCopyWith(_LiveTrackingSession value, $Res Function(_LiveTrackingSession) _then) = __$LiveTrackingSessionCopyWithImpl; +@override @useResult +$Res call({ + LiveTrackingConfig config, LiveTrackingStatus status, DateTime startedAt, int fixCount, int savedCount, int saveErrorCount, GeoPosition? lastFix, String? lastError +}); + + +@override $GeoPositionCopyWith<$Res>? get lastFix; + +} +/// @nodoc +class __$LiveTrackingSessionCopyWithImpl<$Res> + implements _$LiveTrackingSessionCopyWith<$Res> { + __$LiveTrackingSessionCopyWithImpl(this._self, this._then); + + final _LiveTrackingSession _self; + final $Res Function(_LiveTrackingSession) _then; + +/// Create a copy of LiveTrackingSession +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? config = null,Object? status = null,Object? startedAt = null,Object? fixCount = null,Object? savedCount = null,Object? saveErrorCount = null,Object? lastFix = freezed,Object? lastError = freezed,}) { + return _then(_LiveTrackingSession( +config: null == config ? _self.config : config // ignore: cast_nullable_to_non_nullable +as LiveTrackingConfig,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as LiveTrackingStatus,startedAt: null == startedAt ? _self.startedAt : startedAt // ignore: cast_nullable_to_non_nullable +as DateTime,fixCount: null == fixCount ? _self.fixCount : fixCount // ignore: cast_nullable_to_non_nullable +as int,savedCount: null == savedCount ? _self.savedCount : savedCount // ignore: cast_nullable_to_non_nullable +as int,saveErrorCount: null == saveErrorCount ? _self.saveErrorCount : saveErrorCount // ignore: cast_nullable_to_non_nullable +as int,lastFix: freezed == lastFix ? _self.lastFix : lastFix // ignore: cast_nullable_to_non_nullable +as GeoPosition?,lastError: freezed == lastError ? _self.lastError : lastError // ignore: cast_nullable_to_non_nullable +as String?, + )); +} + +/// Create a copy of LiveTrackingSession +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$GeoPositionCopyWith<$Res>? get lastFix { + if (_self.lastFix == null) { + return null; + } + + return $GeoPositionCopyWith<$Res>(_self.lastFix!, (value) { + return _then(_self.copyWith(lastFix: value)); + }); +} +} + +// dart format on diff --git a/pubspec.lock b/pubspec.lock index aa431799..9780164d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -563,7 +563,7 @@ packages: source: hosted version: "4.1.1" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" diff --git a/pubspec.yaml b/pubspec.yaml index 3c0bec4f..cf8f4132 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -101,6 +101,7 @@ dev_dependencies: sdk: flutter flutter_launcher_icons: ^0.14.4 mocktail: ^1.0.3 + fake_async: ^1.3.2 bloc_test: ^9.1.7 flutter_lints: ^2.0.3 build_runner: ^2.4.9 diff --git a/test/utils/services/live_location_tracking/live_location_tracking_service_test.dart b/test/utils/services/live_location_tracking/live_location_tracking_service_test.dart new file mode 100644 index 00000000..b6038a41 --- /dev/null +++ b/test/utils/services/live_location_tracking/live_location_tracking_service_test.dart @@ -0,0 +1,252 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_remote.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; +import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; +import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_stream_settings.dart'; + +class FakeLocationService implements ILocationService { + StreamController? controller; + LocationStreamSettings? lastSettings; + int streamRequests = 0; + + @override + Stream positionStream({ + LocationStreamSettings settings = const LocationStreamSettings(), + }) { + streamRequests++; + lastSettings = settings; + controller = StreamController(); + return controller!.stream; + } + + @override + Future getCurrentPosition() => throw UnimplementedError(); + + @override + Future openAppSettings() async => true; + + @override + Future openLocationSettings() async => true; +} + +class FakeRemote implements ILiveTrackingRemote { + final telemetryCalls = <(LiveTrackingTarget, int, Map)>[]; + final attributeCalls = <(LiveTrackingTarget, Map)>[]; + Object? throwOnTelemetry; + + @override + Future saveTelemetry( + LiveTrackingTarget target, + int ts, + Map values, + ) async { + if (throwOnTelemetry != null) { + throw throwOnTelemetry!; + } + telemetryCalls.add((target, ts, values)); + } + + @override + Future saveAttributes( + LiveTrackingTarget target, + Map attributes, + ) async { + attributeCalls.add((target, attributes)); + } +} + +void main() { + const target = LiveTrackingTarget(entityType: 'DEVICE', id: 'd-1'); + final fix = GeoPosition( + latitude: 1, + longitude: 2, + accuracy: 5, + timestamp: DateTime.fromMillisecondsSinceEpoch(1720000000000), + altitude: 100, + speed: 3, + heading: 90, + ); + + late FakeLocationService location; + late FakeRemote remote; + late LiveLocationTrackingService service; + + setUp(() { + location = FakeLocationService(); + remote = FakeRemote(); + service = LiveLocationTrackingService( + locationService: location, + remote: remote, + logger: TbLogger(), + ); + }); + + test('start emits a tracking session and writes status attributes', () async { + await service.start( + const LiveTrackingConfig(target: target, trackedBy: 'me@tb.io'), + ); + + expect(service.session?.status, LiveTrackingStatus.tracking); + expect(remote.attributeCalls.single.$2, { + 'gpsActive': true, + 'gpsTrackedBy': 'me@tb.io', + }); + expect(location.lastSettings?.background, isNotNull); + }); + + test('start with writeStatusAttributes=false writes nothing', () async { + await service.start( + const LiveTrackingConfig(target: target, writeStatusAttributes: false), + ); + + expect(remote.attributeCalls, isEmpty); + }); + + test( + 'fix saves telemetry with configured keys and gpsLastUpdateTime', + () async { + await service.start( + const LiveTrackingConfig( + target: target, + latitudeKey: 'lat', + longitudeKey: 'lng', + ), + ); + remote.attributeCalls.clear(); + + location.controller!.add(LocationSuccess(fix)); + await pumpEventQueue(); + + final (savedTarget, ts, values) = remote.telemetryCalls.single; + expect(savedTarget.id, 'd-1'); + expect(ts, 1720000000000); + expect(values, {'lat': 1.0, 'lng': 2.0}); + expect(remote.attributeCalls.single.$2, { + 'gpsLastUpdateTime': 1720000000000, + }); + expect(service.session?.fixCount, 1); + expect(service.session?.savedCount, 1); + expect(service.session?.lastFix, fix); + }, + ); + + test('includeMetadata adds gps metadata telemetry keys', () async { + await service.start( + const LiveTrackingConfig( + target: target, + includeMetadata: true, + writeStatusAttributes: false, + ), + ); + + location.controller!.add(LocationSuccess(fix)); + await pumpEventQueue(); + + expect(remote.telemetryCalls.single.$3, { + 'latitude': 1.0, + 'longitude': 2.0, + 'gpsAccuracy': 5.0, + 'gpsAltitude': 100.0, + 'gpsSpeed': 3.0, + 'gpsHeading': 90.0, + }); + }); + + test('mirrorToAttributes copies values into the attribute save', () async { + await service.start( + const LiveTrackingConfig(target: target, mirrorToAttributes: true), + ); + remote.attributeCalls.clear(); + + location.controller!.add(LocationSuccess(fix)); + await pumpEventQueue(); + + expect(remote.attributeCalls.single.$2, { + 'latitude': 1.0, + 'longitude': 2.0, + 'gpsLastUpdateTime': 1720000000000, + }); + }); + + test('save failure increments saveErrorCount and keeps tracking', () async { + await service.start(const LiveTrackingConfig(target: target)); + remote.throwOnTelemetry = Exception('boom'); + + location.controller!.add(LocationSuccess(fix)); + await pumpEventQueue(); + + expect(service.session?.status, LiveTrackingStatus.tracking); + expect(service.session?.saveErrorCount, 1); + expect(service.session?.savedCount, 0); + expect(service.session?.lastError, contains('boom')); + }); + + test( + 'pause cancels the stream and writes gpsActive=false; resume restores', + () async { + await service.start(const LiveTrackingConfig(target: target)); + remote.attributeCalls.clear(); + + await service.pause(); + expect(service.session?.status, LiveTrackingStatus.paused); + expect(remote.attributeCalls.single.$2, {'gpsActive': false}); + expect(location.controller!.hasListener, false); + + remote.attributeCalls.clear(); + await service.resume(); + expect(service.session?.status, LiveTrackingStatus.tracking); + expect(remote.attributeCalls.single.$2, {'gpsActive': true}); + expect(location.streamRequests, 2); + }, + ); + + test('stop clears the session and writes gpsActive=false', () async { + await service.start(const LiveTrackingConfig(target: target)); + remote.attributeCalls.clear(); + final emissions = []; + final sub = service.sessionStream.listen(emissions.add); + + await service.stop(); + await pumpEventQueue(); + + expect(service.session, isNull); + expect(remote.attributeCalls.single.$2, {'gpsActive': false}); + expect(emissions.last, isNull); + await sub.cancel(); + }); + + test( + 'terminal permission failure pauses the session with an error', + () async { + await service.start(const LiveTrackingConfig(target: target)); + + location.controller!.add(const LocationPermissionDenied()); + await pumpEventQueue(); + + expect(service.session?.status, LiveTrackingStatus.paused); + expect(service.session?.lastError, isNotNull); + }, + ); + + test('maxDurationMinutes auto-stops the session', () { + fakeAsync((async) { + service.start( + const LiveTrackingConfig(target: target, maxDurationMinutes: 5), + ); + async.flushMicrotasks(); + expect(service.session, isNotNull); + + async.elapse(const Duration(minutes: 5, seconds: 1)); + async.flushMicrotasks(); + expect(service.session, isNull); + }); + }); +} From 84be37f7cdc555c276dc41813e753cec749fea85 Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 22 Jul 2026 12:49:28 +0300 Subject: [PATCH 12/40] feat(location): add startLiveLocation and stopLiveLocation mobile actions --- .../actions/get_live_location_action.dart | 83 ------------ .../location_action_result_mapper.dart | 4 +- .../actions/start_live_location_action.dart | 40 ++++++ .../actions/stop_live_location_action.dart | 31 +++++ .../mobile_actions/widget_action_handler.dart | 6 +- .../widget_mobile_action_type.dart | 3 +- .../live_location_actions_test.dart | 127 ++++++++++++++++++ 7 files changed, 206 insertions(+), 88 deletions(-) delete mode 100644 lib/utils/services/mobile_actions/actions/get_live_location_action.dart create mode 100644 lib/utils/services/mobile_actions/actions/start_live_location_action.dart create mode 100644 lib/utils/services/mobile_actions/actions/stop_live_location_action.dart create mode 100644 test/utils/services/mobile_actions/live_location_actions_test.dart diff --git a/lib/utils/services/mobile_actions/actions/get_live_location_action.dart b/lib/utils/services/mobile_actions/actions/get_live_location_action.dart deleted file mode 100644 index 7b1ea67e..00000000 --- a/lib/utils/services/mobile_actions/actions/get_live_location_action.dart +++ /dev/null @@ -1,83 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:thingsboard_app/config/routes/v2/router_2.dart'; -import 'package:thingsboard_app/locator.dart'; -import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; -import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; -import 'package:thingsboard_app/utils/services/mobile_actions/actions/location_action_result_mapper.dart'; -import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action.dart'; -import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_result.dart'; -import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_type.dart'; - -/// Live counterpart of [WidgetMobileActionType.getLocation]: subscribes to -/// [ILocationService.positionStream] and reflects the device position as it -/// updates, rather than returning a single fix. -/// -/// NOTE: the dialog below is a placeholder visualization used to exercise the -/// live stream — the production UI is not decided yet. The action is registered -/// under [WidgetMobileActionType.getLiveLocation] but is not yet triggered by -/// any dashboard widget. -class GetLiveLocationAction extends MobileAction - with LocationActionResultMapper { - @override - Future execute( - List args, - InAppWebViewController controller, - ) async { - try { - final service = getIt(); - final context = globalNavigatorKey.currentContext!; - - // Most recent successful fix, handed back when the dialog is dismissed. - LocationFix? lastFix; - - await showDialog( - context: context, - builder: (dialogContext) { - return AlertDialog( - title: const Text('Live location'), - content: StreamBuilder( - stream: service.positionStream(), - builder: (ctx, snapshot) { - final fix = snapshot.data; - if (fix is LocationSuccess) { - lastFix = fix; - } - return Text(_describe(fix)); - }, - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(), - child: const Text('Close'), - ), - ], - ); - }, - ); - - return lastFix == null - ? WidgetMobileActionResult.emptyResult() - : mapLocationFixToResult(lastFix!); - } catch (e) { - return handleError(e); - } - } - - String _describe(LocationFix? fix) => switch (fix) { - null => 'Waiting for first GPS fix…', - LocationSuccess(:final position) => - 'lat: ${position.latitude.toStringAsFixed(6)}\n' - 'lng: ${position.longitude.toStringAsFixed(6)}\n' - 'accuracy: ${position.accuracy.toStringAsFixed(1)} m\n' - 'updated: ${position.timestamp}', - LocationServicesDisabled() => 'Location services are disabled.', - LocationPermissionDenied() => 'Location permission denied.', - LocationPermissionDeniedForever() => - 'Location permission permanently denied.', - LocationFixError(:final message) => 'Error: $message', - }; - - @override - WidgetMobileActionType get type => WidgetMobileActionType.getLiveLocation; -} diff --git a/lib/utils/services/mobile_actions/actions/location_action_result_mapper.dart b/lib/utils/services/mobile_actions/actions/location_action_result_mapper.dart index 3336d8ab..881a0945 100644 --- a/lib/utils/services/mobile_actions/actions/location_action_result_mapper.dart +++ b/lib/utils/services/mobile_actions/actions/location_action_result_mapper.dart @@ -3,8 +3,8 @@ import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action_resu import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_result.dart'; /// Shared mapping of a [LocationFix] to a [WidgetMobileActionResult], used by -/// both the one-shot `GetLocationAction` and the live `GetLiveLocationAction` -/// so the success/failure result contract stays identical between them. +/// the one-shot `GetLocationAction` so its success/failure result contract +/// stays in one place. mixin LocationActionResultMapper { WidgetMobileActionResult mapLocationFixToResult(LocationFix fix) { return switch (fix) { diff --git a/lib/utils/services/mobile_actions/actions/start_live_location_action.dart b/lib/utils/services/mobile_actions/actions/start_live_location_action.dart new file mode 100644 index 00000000..838f7fa6 --- /dev/null +++ b/lib/utils/services/mobile_actions/actions/start_live_location_action.dart @@ -0,0 +1,40 @@ +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action_result.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_result.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_type.dart'; + +/// Starts a live tracking session from a fully-resolved dashboard config +/// (see the phase 1c wire protocol). Replaces any active session. +class StartLiveLocationAction extends MobileAction { + @override + Future execute( + List args, + InAppWebViewController controller, + ) async { + try { + if (args.length < 2 || args[1] is! Map) { + return WidgetMobileActionResult.errorResult( + 'Live tracking config is missing.', + ); + } + final config = LiveTrackingConfig.fromJson( + Map.from(args[1] as Map), + ); + await getIt().start(config); + return WidgetMobileActionResult.successResult( + MobileActionResult.launched(true), + ); + } on FormatException catch (e) { + return WidgetMobileActionResult.errorResult(e.message); + } catch (e) { + return handleError(e); + } + } + + @override + WidgetMobileActionType get type => WidgetMobileActionType.startLiveLocation; +} diff --git a/lib/utils/services/mobile_actions/actions/stop_live_location_action.dart b/lib/utils/services/mobile_actions/actions/stop_live_location_action.dart new file mode 100644 index 00000000..b6855114 --- /dev/null +++ b/lib/utils/services/mobile_actions/actions/stop_live_location_action.dart @@ -0,0 +1,31 @@ +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action_result.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_result.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_type.dart'; + +class StopLiveLocationAction extends MobileAction { + @override + Future execute( + List args, + InAppWebViewController controller, + ) async { + try { + final service = getIt(); + if (service.session == null) { + return WidgetMobileActionResult.emptyResult(); + } + await service.stop(); + return WidgetMobileActionResult.successResult( + MobileActionResult.launched(true), + ); + } catch (e) { + return handleError(e); + } + } + + @override + WidgetMobileActionType get type => WidgetMobileActionType.stopLiveLocation; +} diff --git a/lib/utils/services/mobile_actions/widget_action_handler.dart b/lib/utils/services/mobile_actions/widget_action_handler.dart index 4d49afbc..d8453c25 100644 --- a/lib/utils/services/mobile_actions/widget_action_handler.dart +++ b/lib/utils/services/mobile_actions/widget_action_handler.dart @@ -1,11 +1,12 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/device_provisioning_action.dart'; -import 'package:thingsboard_app/utils/services/mobile_actions/actions/get_live_location_action.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/get_location_action.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/make_phone_call_action.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/scan_qr_action.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/show_map_location_action.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/show_map_with_directions_action.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/actions/start_live_location_action.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/actions/stop_live_location_action.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/take_photo_action.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/take_picture_from_gallery_action.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/take_screenshot_action.dart'; @@ -24,7 +25,8 @@ class WidgetActionHandler { ScanQrAction(), MakePhoneCallAction(), GetLocationAction(), - GetLiveLocationAction(), + StartLiveLocationAction(), + StopLiveLocationAction(), TakeScreenshotAction(), ]; Future> handleWidgetMobileAction( diff --git a/lib/utils/services/mobile_actions/widget_mobile_action_type.dart b/lib/utils/services/mobile_actions/widget_mobile_action_type.dart index 420d0892..d92ca8c8 100644 --- a/lib/utils/services/mobile_actions/widget_mobile_action_type.dart +++ b/lib/utils/services/mobile_actions/widget_mobile_action_type.dart @@ -6,7 +6,8 @@ enum WidgetMobileActionType { scanQrCode, makePhoneCall, getLocation, - getLiveLocation, + startLiveLocation, + stopLiveLocation, takeScreenshot, deviceProvision, unknown; diff --git a/test/utils/services/mobile_actions/live_location_actions_test.dart b/test/utils/services/mobile_actions/live_location_actions_test.dart new file mode 100644 index 00000000..accfd82a --- /dev/null +++ b/test/utils/services/mobile_actions/live_location_actions_test.dart @@ -0,0 +1,127 @@ +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/actions/start_live_location_action.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/actions/stop_live_location_action.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_type.dart'; + +class FakeController extends Fake implements InAppWebViewController {} + +class FakeTrackingService implements ILiveLocationTrackingService { + LiveTrackingConfig? startedWith; + bool stopped = false; + + @override + LiveTrackingSession? session; + + @override + Stream get sessionStream => const Stream.empty(); + + @override + Future start(LiveTrackingConfig config) async { + startedWith = config; + } + + @override + Future stop() async { + stopped = true; + } + + @override + Future pause() async {} + + @override + Future resume() async {} +} + +void main() { + late FakeTrackingService tracking; + + setUp(() { + tracking = FakeTrackingService(); + GetIt.I.registerLazySingleton(() => tracking); + }); + + tearDown(() async { + await GetIt.I.reset(); + }); + + test('action type strings parse to the new enum values', () { + expect( + WidgetMobileActionType.fromString('startLiveLocation'), + WidgetMobileActionType.startLiveLocation, + ); + expect( + WidgetMobileActionType.fromString('stopLiveLocation'), + WidgetMobileActionType.stopLiveLocation, + ); + }); + + test( + 'start action parses config, starts service, returns launched', + () async { + final result = await StartLiveLocationAction().execute([ + 'startLiveLocation', + { + 'target': {'entityType': 'DEVICE', 'id': 'd-1'}, + 'trackedBy': 'me@tb.io', + }, + ], FakeController()); + + final json = result.toJson(); + expect(tracking.startedWith?.target.id, 'd-1'); + expect(json['hasResult'], true); + final resultJson = json['result'] as Map; + expect(resultJson['launched'], true); + }, + ); + + test('start action with missing config returns an error result', () async { + final result = await StartLiveLocationAction().execute([ + 'startLiveLocation', + ], FakeController()); + + expect(result.toJson()['hasError'], true); + expect(tracking.startedWith, isNull); + }); + + test('start action with malformed target returns an error result', () async { + final result = await StartLiveLocationAction().execute([ + 'startLiveLocation', + {'latitudeKey': 'lat'}, + ], FakeController()); + + expect(result.toJson()['hasError'], true); + }); + + test('stop action with active session stops and returns launched', () async { + tracking.session = LiveTrackingSession( + config: const LiveTrackingConfig( + target: LiveTrackingTarget(entityType: 'DEVICE', id: 'd-1'), + ), + status: LiveTrackingStatus.tracking, + startedAt: DateTime.fromMillisecondsSinceEpoch(0), + ); + + final result = await StopLiveLocationAction().execute([ + 'stopLiveLocation', + ], FakeController()); + + expect(tracking.stopped, true); + expect(result.toJson()['hasResult'], true); + }); + + test('stop action with no session returns empty result', () async { + final result = await StopLiveLocationAction().execute([ + 'stopLiveLocation', + ], FakeController()); + + final json = result.toJson(); + expect(tracking.stopped, false); + expect(json['hasResult'], false); + expect(json['hasError'], false); + }); +} From 0abefb8ecae575649d52b9f567640df0c28c661b Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 22 Jul 2026 12:54:26 +0300 Subject: [PATCH 13/40] feat(location): add live tracking bar, session screen and provider --- .../routes/location_tracking_routes.dart | 8 + lib/generated/intl/messages_en.dart | 24 ++ lib/generated/l10n.dart | 135 ++++++++ lib/l10n/intl_en.arb | 18 +- .../provider/live_tracking_provider.dart | 46 +++ .../live_tracking_provider.freezed.dart | 298 ++++++++++++++++++ .../provider/live_tracking_provider.g.dart | 27 ++ .../view/live_tracking_session_page.dart | 109 +++++++ .../widgets/live_tracking_bar.dart | 108 +++++++ lib/modules/main/navigation_page.dart | 5 +- .../live_tracking_bar_test.dart | 83 +++++ 11 files changed, 859 insertions(+), 2 deletions(-) create mode 100644 lib/modules/location_tracking/presentation/provider/live_tracking_provider.dart create mode 100644 lib/modules/location_tracking/presentation/provider/live_tracking_provider.freezed.dart create mode 100644 lib/modules/location_tracking/presentation/provider/live_tracking_provider.g.dart create mode 100644 lib/modules/location_tracking/presentation/view/live_tracking_session_page.dart create mode 100644 lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart create mode 100644 test/modules/location_tracking/live_tracking_bar_test.dart diff --git a/lib/config/routes/v2/routes_config/routes/location_tracking_routes.dart b/lib/config/routes/v2/routes_config/routes/location_tracking_routes.dart index 11359e1d..cbaa7788 100644 --- a/lib/config/routes/v2/routes_config/routes/location_tracking_routes.dart +++ b/lib/config/routes/v2/routes_config/routes/location_tracking_routes.dart @@ -1,8 +1,10 @@ import 'package:go_router/go_router.dart'; +import 'package:thingsboard_app/modules/location_tracking/presentation/view/live_tracking_session_page.dart'; import 'package:thingsboard_app/modules/location_tracking/presentation/view/live_tracking_spike_page.dart'; class LocationTrackingRoutes { static const liveTrackingSpike = '/liveTrackingSpike'; + static const liveTrackingSession = '/liveTrackingSession'; } final List locationTrackingRoutes = [ @@ -12,4 +14,10 @@ final List locationTrackingRoutes = [ return const LiveTrackingSpikePage(); }, ), + GoRoute( + path: LocationTrackingRoutes.liveTrackingSession, + builder: (context, state) { + return const LiveTrackingSessionPage(); + }, + ), ]; diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index f36c4182..87f5ea10 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -478,6 +478,30 @@ class MessageLookup extends MessageLookupByLibrary { "listIsEmptyText": MessageLookupByLibrary.simpleMessage( "The list is currently empty.", ), + "liveTrackingActive": MessageLookupByLibrary.simpleMessage( + "Live location tracking", + ), + "liveTrackingErrors": MessageLookupByLibrary.simpleMessage("Errors"), + "liveTrackingFixes": MessageLookupByLibrary.simpleMessage("Fixes"), + "liveTrackingHide": MessageLookupByLibrary.simpleMessage("Hide"), + "liveTrackingLastError": MessageLookupByLibrary.simpleMessage("Last error"), + "liveTrackingLastFix": MessageLookupByLibrary.simpleMessage("Last fix"), + "liveTrackingNoSession": MessageLookupByLibrary.simpleMessage( + "No active tracking session", + ), + "liveTrackingPause": MessageLookupByLibrary.simpleMessage("Pause"), + "liveTrackingPaused": MessageLookupByLibrary.simpleMessage( + "Live tracking paused", + ), + "liveTrackingResume": MessageLookupByLibrary.simpleMessage("Resume"), + "liveTrackingSaved": MessageLookupByLibrary.simpleMessage("Saved"), + "liveTrackingSessionTitle": MessageLookupByLibrary.simpleMessage( + "Live location tracking", + ), + "liveTrackingStarted": MessageLookupByLibrary.simpleMessage("Started"), + "liveTrackingStatus": MessageLookupByLibrary.simpleMessage("Status"), + "liveTrackingStop": MessageLookupByLibrary.simpleMessage("Stop"), + "liveTrackingTarget": MessageLookupByLibrary.simpleMessage("Target entity"), "login": MessageLookupByLibrary.simpleMessage("Log In"), "loginToApp": MessageLookupByLibrary.simpleMessage("Login to app"), "loginToYourAccount": MessageLookupByLibrary.simpleMessage( diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 41eec96a..0bf750d1 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -3313,6 +3313,141 @@ class S { args: [count], ); } + + /// `Live location tracking` + String get liveTrackingActive { + return Intl.message( + 'Live location tracking', + name: 'liveTrackingActive', + desc: '', + args: [], + ); + } + + /// `Live tracking paused` + String get liveTrackingPaused { + return Intl.message( + 'Live tracking paused', + name: 'liveTrackingPaused', + desc: '', + args: [], + ); + } + + /// `Fixes` + String get liveTrackingFixes { + return Intl.message('Fixes', name: 'liveTrackingFixes', desc: '', args: []); + } + + /// `Saved` + String get liveTrackingSaved { + return Intl.message('Saved', name: 'liveTrackingSaved', desc: '', args: []); + } + + /// `Errors` + String get liveTrackingErrors { + return Intl.message( + 'Errors', + name: 'liveTrackingErrors', + desc: '', + args: [], + ); + } + + /// `Stop` + String get liveTrackingStop { + return Intl.message('Stop', name: 'liveTrackingStop', desc: '', args: []); + } + + /// `Pause` + String get liveTrackingPause { + return Intl.message('Pause', name: 'liveTrackingPause', desc: '', args: []); + } + + /// `Resume` + String get liveTrackingResume { + return Intl.message( + 'Resume', + name: 'liveTrackingResume', + desc: '', + args: [], + ); + } + + /// `Hide` + String get liveTrackingHide { + return Intl.message('Hide', name: 'liveTrackingHide', desc: '', args: []); + } + + /// `Live location tracking` + String get liveTrackingSessionTitle { + return Intl.message( + 'Live location tracking', + name: 'liveTrackingSessionTitle', + desc: '', + args: [], + ); + } + + /// `No active tracking session` + String get liveTrackingNoSession { + return Intl.message( + 'No active tracking session', + name: 'liveTrackingNoSession', + desc: '', + args: [], + ); + } + + /// `Target entity` + String get liveTrackingTarget { + return Intl.message( + 'Target entity', + name: 'liveTrackingTarget', + desc: '', + args: [], + ); + } + + /// `Status` + String get liveTrackingStatus { + return Intl.message( + 'Status', + name: 'liveTrackingStatus', + desc: '', + args: [], + ); + } + + /// `Started` + String get liveTrackingStarted { + return Intl.message( + 'Started', + name: 'liveTrackingStarted', + desc: '', + args: [], + ); + } + + /// `Last fix` + String get liveTrackingLastFix { + return Intl.message( + 'Last fix', + name: 'liveTrackingLastFix', + desc: '', + args: [], + ); + } + + /// `Last error` + String get liveTrackingLastError { + return Intl.message( + 'Last error', + name: 'liveTrackingLastError', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 66612d92..8169bae3 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -470,5 +470,21 @@ "type": "int" } } - } + }, + "liveTrackingActive": "Live location tracking", + "liveTrackingPaused": "Live tracking paused", + "liveTrackingFixes": "Fixes", + "liveTrackingSaved": "Saved", + "liveTrackingErrors": "Errors", + "liveTrackingStop": "Stop", + "liveTrackingPause": "Pause", + "liveTrackingResume": "Resume", + "liveTrackingHide": "Hide", + "liveTrackingSessionTitle": "Live location tracking", + "liveTrackingNoSession": "No active tracking session", + "liveTrackingTarget": "Target entity", + "liveTrackingStatus": "Status", + "liveTrackingStarted": "Started", + "liveTrackingLastFix": "Last fix", + "liveTrackingLastError": "Last error" } \ No newline at end of file diff --git a/lib/modules/location_tracking/presentation/provider/live_tracking_provider.dart b/lib/modules/location_tracking/presentation/provider/live_tracking_provider.dart new file mode 100644 index 00000000..a57442e4 --- /dev/null +++ b/lib/modules/location_tracking/presentation/provider/live_tracking_provider.dart @@ -0,0 +1,46 @@ +import 'dart:async'; + +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; + +part 'live_tracking_provider.freezed.dart'; +part 'live_tracking_provider.g.dart'; + +@freezed +abstract class LiveTrackingViewState with _$LiveTrackingViewState { + const factory LiveTrackingViewState({ + LiveTrackingSession? session, + @Default(false) bool hidden, + }) = _LiveTrackingViewState; +} + +@riverpod +class LiveTracking extends _$LiveTracking { + late final StreamSubscription _listener; + + @override + LiveTrackingViewState build() { + final service = getIt(); + _listener = service.sessionStream.listen((session) { + state = LiveTrackingViewState( + session: session, + hidden: session != null && state.hidden, + ); + }); + ref.onDispose(() => _listener.cancel()); + return LiveTrackingViewState(session: service.session); + } + + void hide() => state = state.copyWith(hidden: true); + + void show() => state = state.copyWith(hidden: false); + + Future stop() => getIt().stop(); + + Future pause() => getIt().pause(); + + Future resume() => getIt().resume(); +} diff --git a/lib/modules/location_tracking/presentation/provider/live_tracking_provider.freezed.dart b/lib/modules/location_tracking/presentation/provider/live_tracking_provider.freezed.dart new file mode 100644 index 00000000..2100b135 --- /dev/null +++ b/lib/modules/location_tracking/presentation/provider/live_tracking_provider.freezed.dart @@ -0,0 +1,298 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'live_tracking_provider.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$LiveTrackingViewState { + + LiveTrackingSession? get session; bool get hidden; +/// Create a copy of LiveTrackingViewState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LiveTrackingViewStateCopyWith get copyWith => _$LiveTrackingViewStateCopyWithImpl(this as LiveTrackingViewState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LiveTrackingViewState&&(identical(other.session, session) || other.session == session)&&(identical(other.hidden, hidden) || other.hidden == hidden)); +} + + +@override +int get hashCode => Object.hash(runtimeType,session,hidden); + +@override +String toString() { + return 'LiveTrackingViewState(session: $session, hidden: $hidden)'; +} + + +} + +/// @nodoc +abstract mixin class $LiveTrackingViewStateCopyWith<$Res> { + factory $LiveTrackingViewStateCopyWith(LiveTrackingViewState value, $Res Function(LiveTrackingViewState) _then) = _$LiveTrackingViewStateCopyWithImpl; +@useResult +$Res call({ + LiveTrackingSession? session, bool hidden +}); + + +$LiveTrackingSessionCopyWith<$Res>? get session; + +} +/// @nodoc +class _$LiveTrackingViewStateCopyWithImpl<$Res> + implements $LiveTrackingViewStateCopyWith<$Res> { + _$LiveTrackingViewStateCopyWithImpl(this._self, this._then); + + final LiveTrackingViewState _self; + final $Res Function(LiveTrackingViewState) _then; + +/// Create a copy of LiveTrackingViewState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? session = freezed,Object? hidden = null,}) { + return _then(_self.copyWith( +session: freezed == session ? _self.session : session // ignore: cast_nullable_to_non_nullable +as LiveTrackingSession?,hidden: null == hidden ? _self.hidden : hidden // ignore: cast_nullable_to_non_nullable +as bool, + )); +} +/// Create a copy of LiveTrackingViewState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LiveTrackingSessionCopyWith<$Res>? get session { + if (_self.session == null) { + return null; + } + + return $LiveTrackingSessionCopyWith<$Res>(_self.session!, (value) { + return _then(_self.copyWith(session: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [LiveTrackingViewState]. +extension LiveTrackingViewStatePatterns on LiveTrackingViewState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LiveTrackingViewState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LiveTrackingViewState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LiveTrackingViewState value) $default,){ +final _that = this; +switch (_that) { +case _LiveTrackingViewState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LiveTrackingViewState value)? $default,){ +final _that = this; +switch (_that) { +case _LiveTrackingViewState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( LiveTrackingSession? session, bool hidden)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LiveTrackingViewState() when $default != null: +return $default(_that.session,_that.hidden);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( LiveTrackingSession? session, bool hidden) $default,) {final _that = this; +switch (_that) { +case _LiveTrackingViewState(): +return $default(_that.session,_that.hidden);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( LiveTrackingSession? session, bool hidden)? $default,) {final _that = this; +switch (_that) { +case _LiveTrackingViewState() when $default != null: +return $default(_that.session,_that.hidden);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _LiveTrackingViewState implements LiveTrackingViewState { + const _LiveTrackingViewState({this.session, this.hidden = false}); + + +@override final LiveTrackingSession? session; +@override@JsonKey() final bool hidden; + +/// Create a copy of LiveTrackingViewState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LiveTrackingViewStateCopyWith<_LiveTrackingViewState> get copyWith => __$LiveTrackingViewStateCopyWithImpl<_LiveTrackingViewState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LiveTrackingViewState&&(identical(other.session, session) || other.session == session)&&(identical(other.hidden, hidden) || other.hidden == hidden)); +} + + +@override +int get hashCode => Object.hash(runtimeType,session,hidden); + +@override +String toString() { + return 'LiveTrackingViewState(session: $session, hidden: $hidden)'; +} + + +} + +/// @nodoc +abstract mixin class _$LiveTrackingViewStateCopyWith<$Res> implements $LiveTrackingViewStateCopyWith<$Res> { + factory _$LiveTrackingViewStateCopyWith(_LiveTrackingViewState value, $Res Function(_LiveTrackingViewState) _then) = __$LiveTrackingViewStateCopyWithImpl; +@override @useResult +$Res call({ + LiveTrackingSession? session, bool hidden +}); + + +@override $LiveTrackingSessionCopyWith<$Res>? get session; + +} +/// @nodoc +class __$LiveTrackingViewStateCopyWithImpl<$Res> + implements _$LiveTrackingViewStateCopyWith<$Res> { + __$LiveTrackingViewStateCopyWithImpl(this._self, this._then); + + final _LiveTrackingViewState _self; + final $Res Function(_LiveTrackingViewState) _then; + +/// Create a copy of LiveTrackingViewState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? session = freezed,Object? hidden = null,}) { + return _then(_LiveTrackingViewState( +session: freezed == session ? _self.session : session // ignore: cast_nullable_to_non_nullable +as LiveTrackingSession?,hidden: null == hidden ? _self.hidden : hidden // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +/// Create a copy of LiveTrackingViewState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LiveTrackingSessionCopyWith<$Res>? get session { + if (_self.session == null) { + return null; + } + + return $LiveTrackingSessionCopyWith<$Res>(_self.session!, (value) { + return _then(_self.copyWith(session: value)); + }); +} +} + +// dart format on diff --git a/lib/modules/location_tracking/presentation/provider/live_tracking_provider.g.dart b/lib/modules/location_tracking/presentation/provider/live_tracking_provider.g.dart new file mode 100644 index 00000000..56956bd7 --- /dev/null +++ b/lib/modules/location_tracking/presentation/provider/live_tracking_provider.g.dart @@ -0,0 +1,27 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'live_tracking_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$liveTrackingHash() => r'34d06583c6c1f73ceb02ee1733d356320f1ad658'; + +/// See also [LiveTracking]. +@ProviderFor(LiveTracking) +final liveTrackingProvider = + AutoDisposeNotifierProvider.internal( + LiveTracking.new, + name: r'liveTrackingProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$liveTrackingHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$LiveTracking = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/modules/location_tracking/presentation/view/live_tracking_session_page.dart b/lib/modules/location_tracking/presentation/view/live_tracking_session_page.dart new file mode 100644 index 00000000..e327dfdd --- /dev/null +++ b/lib/modules/location_tracking/presentation/view/live_tracking_session_page.dart @@ -0,0 +1,109 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:thingsboard_app/generated/l10n.dart'; +import 'package:thingsboard_app/modules/location_tracking/presentation/provider/live_tracking_provider.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; + +class LiveTrackingSessionPage extends ConsumerWidget { + const LiveTrackingSessionPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final session = ref.watch(liveTrackingProvider).session; + return Scaffold( + appBar: AppBar(title: Text(S.of(context).liveTrackingSessionTitle)), + body: + session == null + ? Center(child: Text(S.of(context).liveTrackingNoSession)) + : _SessionDetails(session: session), + ); + } +} + +class _SessionDetails extends ConsumerWidget { + const _SessionDetails({required this.session}); + + final LiveTrackingSession session; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tracking = session.status == LiveTrackingStatus.tracking; + final lastFix = session.lastFix; + return ListView( + children: [ + ListTile( + title: Text(S.of(context).liveTrackingTarget), + subtitle: Text( + '${session.config.target.entityType} ${session.config.target.id}', + ), + ), + ListTile( + title: Text(S.of(context).liveTrackingStatus), + subtitle: Text( + tracking + ? S.of(context).liveTrackingActive + : S.of(context).liveTrackingPaused, + ), + ), + ListTile( + title: Text(S.of(context).liveTrackingStarted), + subtitle: Text(session.startedAt.toLocal().toString()), + ), + ListTile( + title: Text( + '${S.of(context).liveTrackingFixes}: ${session.fixCount} · ' + '${S.of(context).liveTrackingSaved}: ${session.savedCount} · ' + '${S.of(context).liveTrackingErrors}: ${session.saveErrorCount}', + ), + ), + if (lastFix != null) + ListTile( + title: Text(S.of(context).liveTrackingLastFix), + subtitle: Text( + '${lastFix.latitude.toStringAsFixed(6)}, ' + '${lastFix.longitude.toStringAsFixed(6)} ' + '(±${lastFix.accuracy.toStringAsFixed(0)} m)', + ), + ), + if (session.lastError != null) + ListTile( + title: Text(S.of(context).liveTrackingLastError), + subtitle: Text( + session.lastError!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () { + final notifier = ref.read(liveTrackingProvider.notifier); + tracking ? notifier.pause() : notifier.resume(); + }, + icon: Icon(tracking ? Icons.pause : Icons.play_arrow), + label: Text( + tracking + ? S.of(context).liveTrackingPause + : S.of(context).liveTrackingResume, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.icon( + onPressed: + () => ref.read(liveTrackingProvider.notifier).stop(), + icon: const Icon(Icons.stop), + label: Text(S.of(context).liveTrackingStop), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart b/lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart new file mode 100644 index 00000000..af89f63a --- /dev/null +++ b/lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:thingsboard_app/config/routes/v2/routes_config/routes/location_tracking_routes.dart'; +import 'package:thingsboard_app/generated/l10n.dart'; +import 'package:thingsboard_app/modules/location_tracking/presentation/provider/live_tracking_provider.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; + +/// Persistent bar shown on all main pages while a tracking session exists. +class LiveTrackingBar extends ConsumerWidget { + const LiveTrackingBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final viewState = ref.watch(liveTrackingProvider); + final session = viewState.session; + if (session == null) { + return const SizedBox.shrink(); + } + final colors = Theme.of(context).colorScheme; + final tracking = session.status == LiveTrackingStatus.tracking; + + if (viewState.hidden) { + return Material( + color: colors.primaryContainer, + child: InkWell( + onTap: () => ref.read(liveTrackingProvider.notifier).show(), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Icon( + tracking ? Icons.gps_fixed : Icons.gps_off, + size: 16, + color: colors.onPrimaryContainer, + ), + ), + ), + ); + } + + return Material( + color: colors.primaryContainer, + child: InkWell( + onTap: () => context.push(LocationTrackingRoutes.liveTrackingSession), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row( + children: [ + Icon( + tracking ? Icons.gps_fixed : Icons.gps_off, + color: colors.onPrimaryContainer, + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + tracking + ? S.of(context).liveTrackingActive + : S.of(context).liveTrackingPaused, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: colors.onPrimaryContainer, + ), + ), + Text( + '${S.of(context).liveTrackingFixes}: ' + '${session.fixCount} · ' + '${S.of(context).liveTrackingSaved}: ' + '${session.savedCount}' + '${session.saveErrorCount > 0 ? ' · ${S.of(context).liveTrackingErrors}: ${session.saveErrorCount}' : ''}', + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: colors.onPrimaryContainer, + ), + ), + ], + ), + ), + IconButton( + tooltip: + tracking + ? S.of(context).liveTrackingPause + : S.of(context).liveTrackingResume, + icon: Icon( + tracking ? Icons.pause : Icons.play_arrow, + color: colors.onPrimaryContainer, + ), + onPressed: () { + final notifier = ref.read(liveTrackingProvider.notifier); + tracking ? notifier.pause() : notifier.resume(); + }, + ), + IconButton( + tooltip: S.of(context).liveTrackingStop, + icon: Icon(Icons.stop, color: colors.onPrimaryContainer), + onPressed: () => ref.read(liveTrackingProvider.notifier).stop(), + ), + IconButton( + tooltip: S.of(context).liveTrackingHide, + icon: Icon(Icons.expand_less, color: colors.onPrimaryContainer), + onPressed: () => ref.read(liveTrackingProvider.notifier).hide(), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/modules/main/navigation_page.dart b/lib/modules/main/navigation_page.dart index 060cb605..7fc85fcd 100644 --- a/lib/modules/main/navigation_page.dart +++ b/lib/modules/main/navigation_page.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:thingsboard_app/constants/app_constants.dart'; import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/modules/location_tracking/presentation/widgets/live_tracking_bar.dart'; import 'package:thingsboard_app/modules/main/model/main_navigation_item.dart'; import 'package:thingsboard_app/modules/main/model/navigation_type.dart'; import 'package:thingsboard_app/modules/main/providers/navigation_helper.dart'; @@ -61,7 +62,9 @@ class NavigationPage extends HookConsumerWidget { canPop: false, child: SafeArea( child: Scaffold( - body: child, + body: Column( + children: [const LiveTrackingBar(), Expanded(child: child)], + ), bottomNavigationBar: currentIndex.value == null ? null diff --git a/test/modules/location_tracking/live_tracking_bar_test.dart b/test/modules/location_tracking/live_tracking_bar_test.dart new file mode 100644 index 00000000..721b7d27 --- /dev/null +++ b/test/modules/location_tracking/live_tracking_bar_test.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:thingsboard_app/generated/l10n.dart'; +import 'package:thingsboard_app/modules/location_tracking/presentation/widgets/live_tracking_bar.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; + +class FakeTrackingService implements ILiveLocationTrackingService { + final controller = StreamController.broadcast(); + bool stopCalled = false; + + @override + LiveTrackingSession? session; + + @override + Stream get sessionStream => controller.stream; + + @override + Future start(LiveTrackingConfig config) async {} + + @override + Future stop() async { + stopCalled = true; + } + + @override + Future pause() async {} + + @override + Future resume() async {} +} + +Widget _wrap(Widget child) => ProviderScope( + child: MaterialApp( + localizationsDelegates: const [S.delegate], + home: Scaffold(body: child), + ), +); + +void main() { + late FakeTrackingService tracking; + + setUp(() { + tracking = FakeTrackingService(); + GetIt.I.registerLazySingleton(() => tracking); + }); + + tearDown(() async { + await GetIt.I.reset(); + }); + + testWidgets('renders nothing without a session', (tester) async { + await tester.pumpWidget(_wrap(const LiveTrackingBar())); + + expect(find.byIcon(Icons.stop), findsNothing); + }); + + testWidgets('shows controls for an active session and stops on tap', ( + tester, + ) async { + tracking.session = LiveTrackingSession( + config: const LiveTrackingConfig( + target: LiveTrackingTarget(entityType: 'DEVICE', id: 'd-1'), + ), + status: LiveTrackingStatus.tracking, + startedAt: DateTime.fromMillisecondsSinceEpoch(0), + ); + + await tester.pumpWidget(_wrap(const LiveTrackingBar())); + await tester.pump(); + + expect(find.byIcon(Icons.stop), findsOneWidget); + expect(find.byIcon(Icons.pause), findsOneWidget); + + await tester.tap(find.byIcon(Icons.stop)); + expect(tracking.stopCalled, true); + }); +} From af0996fcbd2999717b101ba560df7917548b4712 Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 22 Jul 2026 16:00:03 +0300 Subject: [PATCH 14/40] fix(location): grant ACCESS_FINE_LOCATION on Android 14+ for live tracking The ESP32 provisioning branch capped ACCESS_FINE_LOCATION at maxSdkVersion=35, which strips the permission on API 36+ (Android 16). Live GPS tracking then fell back to approximate location and delivered only a single fix. Use tools:remove to drop any dependency-imposed maxSdkVersion so fine location is granted on all API levels. --- android/app/src/main/AndroidManifest.xml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 3c8b5671..25723c32 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,10 +2,11 @@ xmlns:tools="http://schemas.android.com/tools"> + + tools:remove="android:maxSdkVersion" /> From 4f7b3b4de8f956e83f2528aaadf190af20f3d3ab Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 22 Jul 2026 17:28:05 +0300 Subject: [PATCH 15/40] docs: add phase 1d live tracking UX and persistence design spec --- .../2026-07-22-gps-live-tracking-ux-design.md | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-22-gps-live-tracking-ux-design.md diff --git a/docs/superpowers/specs/2026-07-22-gps-live-tracking-ux-design.md b/docs/superpowers/specs/2026-07-22-gps-live-tracking-ux-design.md new file mode 100644 index 00000000..eb3d58e9 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-gps-live-tracking-ux-design.md @@ -0,0 +1,96 @@ +# GPS Live Tracking UX & Persistence (Phase 1d) Design + +**Status:** design — pending user review +**Date:** 2026-07-22 +**Repos:** Flutter `flutter_thingsboard_app` only (mobile-only; no ui-ngx / wire-protocol changes) +**Predecessor:** Phase 1c — `docs/superpowers/specs/2026-07-03-gps-tracking-design.md`, plan `docs/superpowers/plans/2026-07-03-gps-live-tracking.md` + +## Goal + +Polish the phase 1c live-tracking experience on the mobile app: + +1. Show the tracking **target as a human-friendly entity name** instead of `DEVICE `. +2. Replace the debug-only **"GPS tracking spike"** menu entry with a permanent **"Live location tracking"** page. +3. Give that page a useful **idle state**: a persisted summary of the last session plus a **"Start again"** action, or a "nothing tracked" message when there is no history. +4. Polish the **collapsed tracking bar**: a full-width bar with a pulsing "live" icon. + +Explicitly **out of scope** (its own later spec): saving GPS to more than one entity (multi-entity tracking), which would change the wire protocol, the tracking service, and the ui-ngx action editor. + +## Scope note — app-initiated tracking + +Phase 1c starts a session only from a dashboard mobile action. The "Start again" button in this phase lets the app **relaunch a session from a stored config** without a dashboard round-trip. This is a deliberate, small expansion of the model; the config is still exactly what a dashboard action produced earlier. + +--- + +## 1. Friendly entity name + +- A Riverpod `FutureProvider.family` keyed by the target `EntityId` resolves the display name via the Dart client, cached so the session page and idle page share a single lookup. (Exact client call — e.g. an entity-info lookup by `entityType`+`id` — to be confirmed in the plan.) +- **Display:** the resolved name (e.g. `My Tracker Device`). While loading or on failure (offline, or the entity was deleted) it falls back to `DEVICE · `. +- The last successfully resolved name is also written into the persisted record (§3), so the idle page can show a name with no network call. + +## 2. Page promotion (replaces the spike) + +- **Delete** the phase-1a spike: `lib/modules/location_tracking/presentation/view/live_tracking_spike_page.dart`, the `liveTrackingSpike` route constant + `GoRoute`, and the `kDebugMode`-gated menu entry in `lib/modules/more/more_page.dart`. +- Add a **permanent** (non-debug) "Live location tracking" entry on the More page, in the spot the spike entry occupied, opening the tracking page. +- Rename the route constant `liveTrackingSession` → `liveTracking` (and its path) since the page now covers both the active-session and idle states. Update the bar's `context.push(...)` accordingly. +- New l10n key for the menu title (English source in `lib/l10n/intl_en.arb`). + +## 3. Idle state + persistence + +### Storage + +- New interface `ILiveTrackingStore` with a Hive-backed implementation, registered in GetIt (matching the project's interface + GetIt convention). Holds **one** `LastTrackingRecord` (or none). +- `LiveLocationTrackingService` gains a dependency on `ILiveTrackingStore` and: + - **On `start(config)`** — writes a record with the raw config JSON, `startedAt`, and (once resolved) the target name; `endReason = interrupted` provisionally, so an app-kill mid-session still leaves a restartable record. + - **On session end** (`stop` or max-duration — a terminal error *pauses* rather than ends, per phase 1c) — updates the record with `endedAt`, the final `fixCount`/`savedCount`/`saveErrorCount`, last coordinates, `lastError` if any, and the real `endReason`. +- **Cleared on logout** (device-global record; a different user must not see the previous user's last target). Hook into the existing logout/user-changed path. + +### Data model + +``` +LastTrackingRecord { + configJson: Map // raw wire config, replayed by "Start again" + targetName: String? // cached resolved name for offline idle display + startedAt: DateTime + endedAt: DateTime? + fixCount: int + savedCount: int + saveErrorCount: int + lastLat: double? + lastLng: double? + lastError: String? // last terminal error seen, if any + endReason: manual | maxDuration | interrupted +} +``` + +### Page behavior (`LiveTrackingPage`) + +- **Active session** → live details as today, with the friendly name in the Target row. +- **Idle with a record** → read-only summary (name, started/ended, counts, last coordinates, end reason) + a **"Start again"** button that calls `service.start(storedConfig)`, with `trackedBy` **re-derived from the current logged-in user** (not the stored email). +- **Idle, no record** → "No active tracking" message. + +## 4. Collapsed-bar polish + +- The collapsed (hidden) pill becomes a **full-width** `primaryContainer` bar (currently a small centered chip). +- Centered **pulsing icon** while `status == tracking`: an `AnimationController` with `repeat(reverse: true)` fading/scaling the `gps_fixed` icon. While `paused`, show a static `gps_off` icon (no pulse — it is not live). +- Tap still expands the bar (`show()`). +- The collapsed pill becomes a small `HookConsumerWidget` (the file already uses `hooks_riverpod`) so `useAnimationController` drives the pulse; the expanded bar is unchanged. + +## Error handling + +- Name resolution failure → silent fallback to `DEVICE · `; never blocks the page. +- Store read/write failure → logged via `TbLogger`, treated as "no record" (page still renders); never crashes tracking. +- "Start again" reuses the phase-1c `start()` path, so location-permission / services failures surface through the existing session error handling (paused + `lastError`). + +## Testing + +- `ILiveTrackingStore` unit tests: save/read round-trip; start-writes-then-end-updates; clear-on-logout. +- Name-fallback unit test: resolved name vs `DEVICE · ` on failure. +- Widget test for the bar: tracking → `gps_fixed`, paused → `gps_off`, collapsed bar spans full width. The pulse animation itself is not asserted. +- Existing phase-1c service tests must keep passing; the added store dependency is injected via a fake in those tests. + +## Out of scope / follow-ups + +- Multi-entity tracking (separate spec). +- History of more than one past session (only the single last record is kept). +- Editing tracking parameters from the app (config is replayed verbatim). From 6b5b318856e0b82501a2847b3af85ba3d70e94c6 Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 22 Jul 2026 17:39:44 +0300 Subject: [PATCH 16/40] docs: make phase 1d page bundle-gated; note browser-location-save follow-up --- .../2026-07-22-gps-live-tracking-ux-design.md | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-07-22-gps-live-tracking-ux-design.md b/docs/superpowers/specs/2026-07-22-gps-live-tracking-ux-design.md index eb3d58e9..4013b8c4 100644 --- a/docs/superpowers/specs/2026-07-22-gps-live-tracking-ux-design.md +++ b/docs/superpowers/specs/2026-07-22-gps-live-tracking-ux-design.md @@ -2,7 +2,7 @@ **Status:** design — pending user review **Date:** 2026-07-22 -**Repos:** Flutter `flutter_thingsboard_app` only (mobile-only; no ui-ngx / wire-protocol changes) +**Repos:** Flutter `flutter_thingsboard_app` (UX + persistence) **and** `thingsboard` (backend `DefaultPageId` + ui-ngx bundle-layout editor) for the bundle-gated menu entry. No wire-protocol changes. **Predecessor:** Phase 1c — `docs/superpowers/specs/2026-07-03-gps-tracking-design.md`, plan `docs/superpowers/plans/2026-07-03-gps-live-tracking.md` ## Goal @@ -10,7 +10,7 @@ Polish the phase 1c live-tracking experience on the mobile app: 1. Show the tracking **target as a human-friendly entity name** instead of `DEVICE `. -2. Replace the debug-only **"GPS tracking spike"** menu entry with a permanent **"Live location tracking"** page. +2. Replace the debug-only **"GPS tracking spike"** menu entry with a **"Live location tracking"** page that admins include/exclude per mobile bundle (server-driven, hidden by default). 3. Give that page a useful **idle state**: a persisted summary of the last session plus a **"Start again"** action, or a "nothing tracked" message when there is no history. 4. Polish the **collapsed tracking bar**: a full-width bar with a pulsing "live" icon. @@ -28,12 +28,26 @@ Phase 1c starts a session only from a dashboard mobile action. The "Start again" - **Display:** the resolved name (e.g. `My Tracker Device`). While loading or on failure (offline, or the entity was deleted) it falls back to `DEVICE · `. - The last successfully resolved name is also written into the persisted record (§3), so the idle page can show a name with no network call. -## 2. Page promotion (replaces the spike) +## 2. Page promotion — bundle-gated menu entry (replaces the spike) -- **Delete** the phase-1a spike: `lib/modules/location_tracking/presentation/view/live_tracking_spike_page.dart`, the `liveTrackingSpike` route constant + `GoRoute`, and the `kDebugMode`-gated menu entry in `lib/modules/more/more_page.dart`. -- Add a **permanent** (non-debug) "Live location tracking" entry on the More page, in the spot the spike entry occupied, opening the tracking page. -- Rename the route constant `liveTrackingSession` → `liveTracking` (and its path) since the page now covers both the active-session and idle states. Update the bar's `context.push(...)` accordingly. -- New l10n key for the menu title (English source in `lib/l10n/intl_en.arb`). +The app's menu is server-driven: the mobile bundle sends a page layout the app maps through the `Pages` enum (`lib/utils/services/layouts/pages_layout.dart`) and renders via `ILayoutService.getMorePageItems(...)`. So exposure of the new page is a bundle page type, not a hardcoded entry — admins toggle it in **Mobile center → Bundles → Layout**, and it is **hidden by default**. + +**Backend (`thingsboard`):** +- Add `LIVE_LOCATION_TRACKING` to `common/data/.../mobile/layout/DefaultPageId.java`. +- Wire it through the default-page plumbing (`DefaultMobilePage` / `MobileLayoutConfig` defaults + any validation) so the bundle editor and stored layouts accept it. Not in the default visible set (opt-in). + +**ui-ngx (`thingsboard`):** +- Mirror the new page id in the bundle-layout models/labels (`ui-ngx/src/app/shared/models/mobile-app.models.ts`) with a display label + icon, so the Layout editor (`pages/mobile/bundes/layout/`) lists it as a toggle. +- Add an en_US locale string for the page name. + +**App (`flutter_thingsboard_app`):** +- Add `live_location_tracking` to the `Pages` enum and parsing. +- Map it in `layout_service.dart` (`getMorePageItems`) to the tracking page's route, icon, and default label; it renders only when the resolved bundle layout includes it. +- **Delete** the phase-1a spike: `lib/modules/location_tracking/presentation/view/live_tracking_spike_page.dart`, the `liveTrackingSpike` route constant + `GoRoute`, and the `kDebugMode`-gated entry in `lib/modules/more/more_page.dart`. +- Rename the route constant `liveTrackingSession` → `liveTracking` (and its path); the page now covers both active-session and idle states. Update the bar's `context.push(...)` accordingly. +- New l10n key for the page title in `lib/l10n/intl_en.arb`. + +This bundle entry is also what makes the idle "last session" page reachable when no session is active (the tracking bar only exists during/after a session). ## 3. Idle state + persistence @@ -91,6 +105,12 @@ LastTrackingRecord { ## Out of scope / follow-ups -- Multi-entity tracking (separate spec). +Tracked as their own future specs (not part of 1d): + +- **Multi-entity tracking** — save each GPS fix to more than one target entity; changes the wire protocol, tracking service, and ui-ngx action editor. +- **Browser-side location save** — a new widget action available from the outer widget scope (like "Place map item") that reads the *browser's* geolocation and saves coordinates to an entity (current / user / alias / attribute-derived). Web-only (ui-ngx + existing telemetry-save APIs); does not touch the mobile app. + +Deliberately excluded from 1d itself: + - History of more than one past session (only the single last record is kept). - Editing tracking parameters from the app (config is replayed verbatim). From b5b483e93146a8bfc7ec1d14c043d391f82a9ec3 Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 22 Jul 2026 17:59:12 +0300 Subject: [PATCH 17/40] docs: add phase 1d live tracking UX implementation plan --- .../plans/2026-07-22-gps-live-tracking-ux.md | 1742 +++++++++++++++++ 1 file changed, 1742 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-22-gps-live-tracking-ux.md diff --git a/docs/superpowers/plans/2026-07-22-gps-live-tracking-ux.md b/docs/superpowers/plans/2026-07-22-gps-live-tracking-ux.md new file mode 100644 index 00000000..06cea8ae --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-gps-live-tracking-ux.md @@ -0,0 +1,1742 @@ +# GPS Live Tracking UX & Persistence (Phase 1d) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Polish the phase-1c live-tracking experience: friendly entity name, a bundle-gated "Live location tracking" page (replacing the debug spike) with a persisted last-session + "Start again" idle state, and a full-width pulsing collapsed bar. + +**Architecture:** The menu page is registered as a server-driven mobile-bundle page type (`thingsboard` repo: one backend enum value + one ui-ngx models file), hidden by default and mapped to a route app-side. The app persists one `LastTrackingRecord` via `TbStorage` (written at session start, updated at end), resolves the target's display name through the Dart client's entity-data query, and relaunches a stored config from the idle page. + +**Tech Stack:** Java (thingsboard backend), Angular 18 (ui-ngx), Flutter/Dart, Riverpod codegen, `built_value` Dart client, `flutter_hooks`, `TbStorage` (secure storage). + +## Global Constraints + +- Repos: `thingsboard` at `/home/artem/projects/thingsboard` branch `feat/gps-tracker` (backend + ui-ngx); Flutter at `/home/artem/projects/mobile/flutter_thingsboard_app` branch `feat/gps-tracker`. Leave the pre-existing uncommitted `ui-ngx/proxy.conf.js` change alone. +- Conventional Commits; **no** `Co-Authored-By` lines. +- Dart: `dart format` on changed files only; `flutter analyze` must stay clean for changed files; codegen via `flutter pub run build_runner build --delete-conflicting-outputs` after touching `@freezed`/`@riverpod`; l10n via `flutter pub run intl_utils:generate`. +- ui-ngx verification: `cd ui-ngx && npx tsc --noEmit -p src/tsconfig.app.json` — expect exit 0 (only pre-existing photoswipe errors, if any). No new ui-ngx test scaffolding (repo convention). +- Production Flutter UI strings are localized (`S.of(context).key`, keys added to `lib/l10n/intl_en.arb` only — other locales fall back to English). +- The page id string on the wire is `LIVE_LOCATION_TRACKING` (backend `DefaultPageId` / ui-ngx `MobileMenuPath`); the app's `Pages` enum value is `live_location_tracking` (its `.name.toUpperCase()` must equal `LIVE_LOCATION_TRACKING`, which `pagesFromString` relies on). +- Defaults locked in design: name resolved app-side via API with fallback `DEVICE · `; "Start again" re-derives `trackedBy` from the current logged-in user; the record is device-global and cleared on logout. Spec: `docs/superpowers/specs/2026-07-22-gps-live-tracking-ux-design.md`. + +--- + +### Task 1: thingsboard repo — register the `LIVE_LOCATION_TRACKING` bundle page (backend + ui-ngx) + +**Files:** +- Modify: `/home/artem/projects/thingsboard/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DefaultPageId.java` +- Modify: `/home/artem/projects/thingsboard/ui-ngx/src/app/shared/models/mobile-app.models.ts` (enum ~L96-107, `defaultMobileMenu` ~L169-179, `hideDefaultMenuItems` ~L181-184, `defaultMobilePageMap` ~L232-305) + +**Interfaces:** +- Produces: a default mobile page id `LIVE_LOCATION_TRACKING`, present in every bundle layout but `visible: false` by default (admin toggles it on in Mobile center → Bundles → Layout). Icon `my_location`, label `Live location tracking`. + +- [ ] **Step 1: Add the backend enum constant** + +In `DefaultPageId.java`, add `LIVE_LOCATION_TRACKING` after `DASHBOARDS`: + +```java +public enum DefaultPageId { + + HOME, + ALARMS, + DEVICES, + CUSTOMERS, + ASSETS, + AUDIT_LOGS, + NOTIFICATIONS, + DEVICE_LIST, + DASHBOARDS, + LIVE_LOCATION_TRACKING +} +``` + +- [ ] **Step 2: Add the ui-ngx enum member** + +In `mobile-app.models.ts`, add to the `MobileMenuPath` enum: + +```ts + NOTIFICATIONS = 'NOTIFICATIONS', + LIVE_LOCATION_TRACKING = 'LIVE_LOCATION_TRACKING' +``` + +- [ ] **Step 3: Seed it into the default menu, hidden by default** + +Append to `defaultMobileMenu`: + +```ts + MobileMenuPath.DASHBOARDS, + MobileMenuPath.LIVE_LOCATION_TRACKING +]; +``` + +Append to `hideDefaultMenuItems` (this makes it listed-but-off by default): + +```ts +export const hideDefaultMenuItems = [ + MobileMenuPath.DEVICE_LIST, + MobileMenuPath.DASHBOARDS, + MobileMenuPath.LIVE_LOCATION_TRACKING +]; +``` + +- [ ] **Step 4: Add the icon/label map entry** + +Add to `defaultMobilePageMap`: + +```ts + [ MobileMenuPath.LIVE_LOCATION_TRACKING, { id: MobileMenuPath.LIVE_LOCATION_TRACKING, icon: 'my_location', label: 'Live location tracking' } ] +``` + +- [ ] **Step 5: Verify ui-ngx compiles** + +Run: `cd /home/artem/projects/thingsboard/ui-ngx && npx tsc --noEmit -p src/tsconfig.app.json` +Expected: exit 0 (only any pre-existing photoswipe errors). + +- [ ] **Step 6: Commit** + +```bash +cd /home/artem/projects/thingsboard +git add common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DefaultPageId.java ui-ngx/src/app/shared/models/mobile-app.models.ts +git commit -m "feat(mobile): add Live location tracking as a hidden-by-default bundle page" +``` + +--- + +### Task 2: Flutter — `LiveTrackingConfig.toJson` (round-trip for persistence) + +**Files:** +- Modify: `lib/utils/services/live_location_tracking/model/live_tracking_config.dart` +- Test: `test/utils/services/live_location_tracking/live_tracking_config_test.dart` (existing — add cases) + +**Interfaces:** +- Produces: `LiveTrackingConfig.toJson() → Map` and `LiveTrackingTarget.toJson()`; `LiveTrackingConfig.fromJson(config.toJson())` reproduces an equal config. Consumed by Task 4 (record) and Task 7 ("Start again"). + +- [ ] **Step 1: Add the failing round-trip test** + +Append to `test/utils/services/live_location_tracking/live_tracking_config_test.dart` inside `main()`: + +```dart + test('toJson round-trips through fromJson', () { + const original = LiveTrackingConfig( + target: LiveTrackingTarget(entityType: 'DEVICE', id: 'abc-123'), + latitudeKey: 'lat', + longitudeKey: 'lng', + includeMetadata: true, + mirrorToAttributes: true, + accuracy: LocationAccuracyLevel.high, + distanceFilterMeters: 25, + intervalSeconds: 60, + maxDurationMinutes: 120, + writeStatusAttributes: false, + trackedBy: 'user@example.com', + ); + + final restored = LiveTrackingConfig.fromJson(original.toJson()); + + expect(restored.target.entityType, 'DEVICE'); + expect(restored.target.id, 'abc-123'); + expect(restored.latitudeKey, 'lat'); + expect(restored.longitudeKey, 'lng'); + expect(restored.includeMetadata, true); + expect(restored.mirrorToAttributes, true); + expect(restored.accuracy, LocationAccuracyLevel.high); + expect(restored.distanceFilterMeters, 25); + expect(restored.intervalSeconds, 60); + expect(restored.maxDurationMinutes, 120); + expect(restored.writeStatusAttributes, false); + expect(restored.trackedBy, 'user@example.com'); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/utils/services/live_location_tracking/live_tracking_config_test.dart` +Expected: FAIL — `toJson` not defined. + +- [ ] **Step 3: Implement `toJson`** + +In `LiveTrackingTarget`, add: + +```dart + Map toJson() => {'entityType': entityType, 'id': id}; +``` + +In `LiveTrackingConfig`, add (and a private `_accuracyToString`): + +```dart + Map toJson() => { + 'target': target.toJson(), + 'latitudeKey': latitudeKey, + 'longitudeKey': longitudeKey, + 'includeMetadata': includeMetadata, + 'mirrorToAttributes': mirrorToAttributes, + 'accuracy': _accuracyToString(accuracy), + 'distanceFilterMeters': distanceFilterMeters, + 'intervalSeconds': intervalSeconds, + 'maxDurationMinutes': maxDurationMinutes, + 'writeStatusAttributes': writeStatusAttributes, + 'trackedBy': trackedBy, + }; + + static String _accuracyToString(LocationAccuracyLevel level) => + switch (level) { + LocationAccuracyLevel.high => 'HIGH', + LocationAccuracyLevel.low => 'LOW', + LocationAccuracyLevel.balanced => 'BALANCED', + }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/utils/services/live_location_tracking/live_tracking_config_test.dart` +Expected: PASS (all cases). + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format lib/utils/services/live_location_tracking/model/live_tracking_config.dart test/utils/services/live_location_tracking/live_tracking_config_test.dart +flutter analyze 2>&1 | grep live_tracking_config ; echo "expect no output above" +git add lib/utils/services/live_location_tracking/model/live_tracking_config.dart test/utils/services/live_location_tracking/live_tracking_config_test.dart +git commit -m "feat(location): add LiveTrackingConfig.toJson for session persistence" +``` + +--- + +### Task 3: Flutter — `LastTrackingRecord` model + +**Files:** +- Create: `lib/utils/services/live_location_tracking/model/last_tracking_record.dart` +- Test: `test/utils/services/live_location_tracking/last_tracking_record_test.dart` + +**Interfaces:** +- Consumes: `LiveTrackingConfig` (Task 2). +- Produces: `enum TrackingEndReason { manual, maxDuration, interrupted }`; `LastTrackingRecord` (plain class, not freezed) with fields `configJson: Map`, `targetName: String?`, `startedAt: DateTime`, `endedAt: DateTime?`, `fixCount/savedCount/saveErrorCount: int`, `lastLat/lastLng: double?`, `lastError: String?`, `endReason: TrackingEndReason`; `toJson()`/`fromJson()`; `config` getter returning `LiveTrackingConfig.fromJson(configJson)`. Consumed by Tasks 4, 6, 7. + +- [ ] **Step 1: Write the failing test** + +Create `test/utils/services/live_location_tracking/last_tracking_record_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; + +void main() { + final json = { + 'configJson': { + 'target': {'entityType': 'DEVICE', 'id': 'd-1'}, + }, + 'targetName': 'My Tracker', + 'startedAt': 1720000000000, + 'endedAt': 1720000600000, + 'fixCount': 12, + 'savedCount': 11, + 'saveErrorCount': 1, + 'lastLat': 1.5, + 'lastLng': 2.5, + 'lastError': 'boom', + 'endReason': 'maxDuration', + }; + + test('fromJson/toJson round-trips', () { + final record = LastTrackingRecord.fromJson(json); + expect(record.targetName, 'My Tracker'); + expect(record.startedAt.millisecondsSinceEpoch, 1720000000000); + expect(record.endedAt?.millisecondsSinceEpoch, 1720000600000); + expect(record.fixCount, 12); + expect(record.savedCount, 11); + expect(record.saveErrorCount, 1); + expect(record.lastLat, 1.5); + expect(record.lastLng, 2.5); + expect(record.lastError, 'boom'); + expect(record.endReason, TrackingEndReason.maxDuration); + expect(record.config.target.id, 'd-1'); + expect(LastTrackingRecord.fromJson(record.toJson()).toJson(), record.toJson()); + }); + + test('unknown endReason falls back to interrupted', () { + final record = LastTrackingRecord.fromJson({ + ...json, + 'endReason': 'nonsense', + }); + expect(record.endReason, TrackingEndReason.interrupted); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/utils/services/live_location_tracking/last_tracking_record_test.dart` +Expected: FAIL — file/class don't exist. + +- [ ] **Step 3: Implement the model** + +Create `lib/utils/services/live_location_tracking/model/last_tracking_record.dart`: + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; + +enum TrackingEndReason { manual, maxDuration, interrupted } + +TrackingEndReason _endReasonFromString(String? value) => + TrackingEndReason.values.firstWhere( + (e) => e.name == value, + orElse: () => TrackingEndReason.interrupted, + ); + +/// Snapshot of the most recent tracking session, persisted so the idle page +/// can show it and relaunch it ("Start again"). Written at session start and +/// updated at session end. +class LastTrackingRecord { + const LastTrackingRecord({ + required this.configJson, + required this.startedAt, + required this.endReason, + this.targetName, + this.endedAt, + this.fixCount = 0, + this.savedCount = 0, + this.saveErrorCount = 0, + this.lastLat, + this.lastLng, + this.lastError, + }); + + factory LastTrackingRecord.fromJson(Map json) => + LastTrackingRecord( + configJson: Map.from(json['configJson'] as Map), + targetName: json['targetName'] as String?, + startedAt: DateTime.fromMillisecondsSinceEpoch( + (json['startedAt'] as num).toInt(), + ), + endedAt: + json['endedAt'] == null + ? null + : DateTime.fromMillisecondsSinceEpoch( + (json['endedAt'] as num).toInt(), + ), + fixCount: (json['fixCount'] as num?)?.toInt() ?? 0, + savedCount: (json['savedCount'] as num?)?.toInt() ?? 0, + saveErrorCount: (json['saveErrorCount'] as num?)?.toInt() ?? 0, + lastLat: (json['lastLat'] as num?)?.toDouble(), + lastLng: (json['lastLng'] as num?)?.toDouble(), + lastError: json['lastError'] as String?, + endReason: _endReasonFromString(json['endReason'] as String?), + ); + + final Map configJson; + final String? targetName; + final DateTime startedAt; + final DateTime? endedAt; + final int fixCount; + final int savedCount; + final int saveErrorCount; + final double? lastLat; + final double? lastLng; + final String? lastError; + final TrackingEndReason endReason; + + LiveTrackingConfig get config => LiveTrackingConfig.fromJson(configJson); + + Map toJson() => { + 'configJson': configJson, + 'targetName': targetName, + 'startedAt': startedAt.millisecondsSinceEpoch, + 'endedAt': endedAt?.millisecondsSinceEpoch, + 'fixCount': fixCount, + 'savedCount': savedCount, + 'saveErrorCount': saveErrorCount, + 'lastLat': lastLat, + 'lastLng': lastLng, + 'lastError': lastError, + 'endReason': endReason.name, + }; + + LastTrackingRecord copyWith({ + String? targetName, + DateTime? endedAt, + int? fixCount, + int? savedCount, + int? saveErrorCount, + double? lastLat, + double? lastLng, + String? lastError, + TrackingEndReason? endReason, + }) => LastTrackingRecord( + configJson: configJson, + targetName: targetName ?? this.targetName, + startedAt: startedAt, + endedAt: endedAt ?? this.endedAt, + fixCount: fixCount ?? this.fixCount, + savedCount: savedCount ?? this.savedCount, + saveErrorCount: saveErrorCount ?? this.saveErrorCount, + lastLat: lastLat ?? this.lastLat, + lastLng: lastLng ?? this.lastLng, + lastError: lastError ?? this.lastError, + endReason: endReason ?? this.endReason, + ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/utils/services/live_location_tracking/last_tracking_record_test.dart` +Expected: PASS (2 tests). + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format lib/utils/services/live_location_tracking/model/last_tracking_record.dart test/utils/services/live_location_tracking/last_tracking_record_test.dart +flutter analyze 2>&1 | grep last_tracking_record ; echo "expect no output above" +git add lib/utils/services/live_location_tracking/model/last_tracking_record.dart test/utils/services/live_location_tracking/last_tracking_record_test.dart +git commit -m "feat(location): add LastTrackingRecord model" +``` + +--- + +### Task 4: Flutter — `ILiveTrackingStore` (TbStorage-backed) + DI + +**Files:** +- Create: `lib/utils/services/live_location_tracking/i_live_tracking_store.dart` +- Create: `lib/utils/services/live_location_tracking/live_tracking_store.dart` +- Modify: `lib/constants/database_keys.dart` (add key) +- Modify: `lib/locator.dart` (register after the `ILiveLocationTrackingService` block, ~L73) +- Test: `test/utils/services/live_location_tracking/live_tracking_store_test.dart` + +**Interfaces:** +- Consumes: `LastTrackingRecord` (Task 3), `TbStorage` (`getItem`/`setItem`/`deleteItem`), `TbLogger`. +- Produces: `ILiveTrackingStore { Future read(); Future write(LastTrackingRecord record); Future clear(); }`; GetIt registration `getIt()`. Consumed by Tasks 6, 7, 10. + +- [ ] **Step 1: Write the failing test** + +Create `test/utils/services/live_location_tracking/live_tracking_store_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/thingsboard_client.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; + +class FakeStorage implements TbStorage { + final _map = {}; + + @override + Future getItem(String key) async => _map[key]; + + @override + Future setItem(String key, dynamic value) async => _map[key] = value; + + @override + Future deleteItem(String key) async => _map.remove(key); +} + +void main() { + late FakeStorage storage; + late LiveTrackingStore store; + + setUp(() { + storage = FakeStorage(); + store = LiveTrackingStore(storage: storage, logger: TbLogger()); + }); + + final record = LastTrackingRecord( + configJson: const { + 'target': {'entityType': 'DEVICE', 'id': 'd-1'}, + }, + startedAt: DateTime.fromMillisecondsSinceEpoch(1720000000000), + endReason: TrackingEndReason.interrupted, + targetName: 'My Tracker', + ); + + test('read returns null when nothing stored', () async { + expect(await store.read(), isNull); + }); + + test('write then read round-trips', () async { + await store.write(record); + final read = await store.read(); + expect(read?.targetName, 'My Tracker'); + expect(read?.config.target.id, 'd-1'); + }); + + test('clear removes the record', () async { + await store.write(record); + await store.clear(); + expect(await store.read(), isNull); + }); + + test('read returns null on corrupt json', () async { + await storage.setItem('live_tracking_last_record', '{not valid'); + expect(await store.read(), isNull); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/utils/services/live_location_tracking/live_tracking_store_test.dart` +Expected: FAIL — `LiveTrackingStore` doesn't exist. + +- [ ] **Step 3: Add the storage key** + +In `lib/constants/database_keys.dart`, add a constant alongside the existing keys: + +```dart + static const liveTrackingLastRecord = 'live_tracking_last_record'; +``` + +- [ ] **Step 4: Create the interface** + +Create `lib/utils/services/live_location_tracking/i_live_tracking_store.dart`: + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; + +/// Persists the single most-recent tracking session so the idle page can show +/// and relaunch it. Device-global; cleared on logout. +abstract interface class ILiveTrackingStore { + Future read(); + + Future write(LastTrackingRecord record); + + Future clear(); +} +``` + +- [ ] **Step 5: Implement the store** + +Create `lib/utils/services/live_location_tracking/live_tracking_store.dart`: + +```dart +import 'dart:convert'; + +import 'package:thingsboard_app/constants/database_keys.dart'; +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/thingsboard_client.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; + +class LiveTrackingStore implements ILiveTrackingStore { + LiveTrackingStore({required TbStorage storage, required TbLogger logger}) + : _storage = storage, + _log = logger; + + final TbStorage _storage; + final TbLogger _log; + + @override + Future read() async { + try { + final raw = await _storage.getItem(DatabaseKeys.liveTrackingLastRecord); + if (raw is! String) { + return null; + } + return LastTrackingRecord.fromJson( + Map.from(jsonDecode(raw) as Map), + ); + } catch (e, s) { + _log.error('LiveTrackingStore.read failed', e, s); + return null; + } + } + + @override + Future write(LastTrackingRecord record) async { + try { + await _storage.setItem( + DatabaseKeys.liveTrackingLastRecord, + jsonEncode(record.toJson()), + ); + } catch (e, s) { + _log.error('LiveTrackingStore.write failed', e, s); + } + } + + @override + Future clear() async { + try { + await _storage.deleteItem(DatabaseKeys.liveTrackingLastRecord); + } catch (e, s) { + _log.error('LiveTrackingStore.clear failed', e, s); + } + } +} +``` + +- [ ] **Step 6: Register in GetIt** + +In `lib/locator.dart`, add imports and register after the `ILiveLocationTrackingService` registration: + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_store.dart'; +``` + +```dart + ..registerLazySingleton( + () => LiveTrackingStore(storage: getIt(), logger: getIt()), + ) +``` + +- [ ] **Step 7: Run test, analyze, commit** + +```bash +flutter test test/utils/services/live_location_tracking/live_tracking_store_test.dart +flutter analyze 2>&1 | grep -E "live_tracking_store|database_keys|locator" ; echo "expect no output above" +dart format lib/utils/services/live_location_tracking/i_live_tracking_store.dart lib/utils/services/live_location_tracking/live_tracking_store.dart lib/constants/database_keys.dart lib/locator.dart test/utils/services/live_location_tracking/live_tracking_store_test.dart +git add lib/utils/services/live_location_tracking/ lib/constants/database_keys.dart lib/locator.dart test/utils/services/live_location_tracking/live_tracking_store_test.dart +git commit -m "feat(location): add ILiveTrackingStore for last-session persistence" +``` + +--- + +### Task 5: Flutter — entity name resolver + display-name helper + +**Files:** +- Modify: `lib/utils/services/entity_query_api.dart` (add `createEntityNameQuery`) +- Create: `lib/utils/services/live_location_tracking/i_entity_name_resolver.dart` +- Create: `lib/utils/services/live_location_tracking/entity_name_resolver.dart` +- Create: `lib/utils/services/live_location_tracking/live_tracking_display.dart` (pure fallback helper) +- Modify: `lib/locator.dart` +- Test: `test/utils/services/live_location_tracking/live_tracking_display_test.dart` + +**Interfaces:** +- Consumes: `ITbClientService.client.getEntityQueryControllerApi().findEntityDataByQuery`, the `EntityDataHelpers.field` extension (`entity_query_api.dart`), `LiveTrackingTarget`. +- Produces: `IEntityNameResolver { Future resolveName(String entityType, String id); }` (registered in GetIt); `displayTargetName(String? resolved, LiveTrackingTarget target) → String` returning `resolved` when non-empty else `'${target.entityType} · ${shortId}'`. Consumed by Tasks 6, 7. + +- [ ] **Step 1: Write the failing test (pure helper)** + +Create `test/utils/services/live_location_tracking/live_tracking_display_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_display.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; + +void main() { + const target = LiveTrackingTarget( + entityType: 'DEVICE', + id: 'f3eda640-42e8-11f1-af6c-63e319b36637', + ); + + test('uses resolved name when present', () { + expect(displayTargetName('My Tracker', target), 'My Tracker'); + }); + + test('falls back to type and short id when name is null', () { + expect(displayTargetName(null, target), 'DEVICE · f3eda640'); + }); + + test('falls back when name is empty/whitespace', () { + expect(displayTargetName(' ', target), 'DEVICE · f3eda640'); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/utils/services/live_location_tracking/live_tracking_display_test.dart` +Expected: FAIL — `displayTargetName` not defined. + +- [ ] **Step 3: Implement the pure helper** + +Create `lib/utils/services/live_location_tracking/live_tracking_display.dart`: + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; + +/// Human-friendly label for a tracking target: the resolved entity name when +/// available, otherwise ` · `. +String displayTargetName(String? resolved, LiveTrackingTarget target) { + if (resolved != null && resolved.trim().isNotEmpty) { + return resolved; + } + final shortId = + target.id.length > 8 ? target.id.substring(0, 8) : target.id; + return '${target.entityType} · $shortId'; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/utils/services/live_location_tracking/live_tracking_display_test.dart` +Expected: PASS (3 tests). + +- [ ] **Step 5: Add the name query builder** + +In `lib/utils/services/entity_query_api.dart`, add a static method to `EntityQueryApi` (mirrors the existing `SingleEntityFilter`/`AliasEntityId` built_value pattern; `EntityType.valueOf` maps the wire string): + +```dart + static EntityDataQuery createEntityNameQuery(String entityType, String id) { + return EntityDataQuery( + (b) => + b + ..entityFilter = SingleEntityFilter( + (b) => + b + ..type = 'singleEntity' + ..singleEntity = + AliasEntityId( + (b) => + b + ..entityType = EntityType.valueOf(entityType) + ..id = id, + ).toBuilder(), + ) + ..entityFields = + BuiltList([ + EntityKey( + (b) => + b + ..type = EntityKeyType.ENTITY_FIELD + ..key = 'name', + ), + ]).toBuilder() + ..pageLink = + EntityDataPageLink((b) => b..pageSize = 1).toBuilder(), + ); + } +``` + +- [ ] **Step 6: Create the resolver interface + impl** + +Create `lib/utils/services/live_location_tracking/i_entity_name_resolver.dart`: + +```dart +/// Resolves an entity's display name from its type + id. Returns null on any +/// failure (offline, deleted entity, unknown type) so callers fall back. +abstract interface class IEntityNameResolver { + Future resolveName(String entityType, String id); +} +``` + +Create `lib/utils/services/live_location_tracking/entity_name_resolver.dart`: + +```dart +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/utils/services/entity_query_api.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; + +class EntityNameResolver implements IEntityNameResolver { + EntityNameResolver({required ITbClientService clientService, required TbLogger logger}) + : _clientService = clientService, + _log = logger; + + final ITbClientService _clientService; + final TbLogger _log; + + @override + Future resolveName(String entityType, String id) async { + try { + final query = EntityQueryApi.createEntityNameQuery(entityType, id); + final response = await _clientService.client + .getEntityQueryControllerApi() + .findEntityDataByQuery(entityDataQuery: query); + final data = response.data?.data; + if (data == null || data.isEmpty) { + return null; + } + return data.first.field('name'); + } catch (e, s) { + _log.error('EntityNameResolver.resolveName failed', e, s); + return null; + } + } +} +``` + +- [ ] **Step 7: Register in GetIt** + +In `lib/locator.dart` add imports and register (near the store): + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/entity_name_resolver.dart'; +``` + +```dart + ..registerLazySingleton( + () => EntityNameResolver(clientService: getIt(), logger: getIt()), + ) +``` + +- [ ] **Step 8: Analyze, format, commit** + +```bash +flutter analyze 2>&1 | grep -E "entity_name_resolver|entity_query_api|live_tracking_display|locator" ; echo "expect no output above" +dart format lib/utils/services/entity_query_api.dart lib/utils/services/live_location_tracking/i_entity_name_resolver.dart lib/utils/services/live_location_tracking/entity_name_resolver.dart lib/utils/services/live_location_tracking/live_tracking_display.dart lib/locator.dart test/utils/services/live_location_tracking/live_tracking_display_test.dart +git add lib/utils/services/entity_query_api.dart lib/utils/services/live_location_tracking/ lib/locator.dart test/utils/services/live_location_tracking/live_tracking_display_test.dart +git commit -m "feat(location): add entity name resolver and display-name fallback" +``` + +> Note: `EntityNameResolver` itself is not unit-tested (it wraps the built_value client, which the repo does not mock); its failure path returns null and is covered by the `displayTargetName` fallback tests. Verify the query at runtime in Task 11's smoke test. + +--- + +### Task 6: Flutter — wire store + name resolver + end reasons into the tracking service + +**Files:** +- Modify: `lib/utils/services/live_location_tracking/live_location_tracking_service.dart` +- Modify: `lib/locator.dart` (pass new deps into `LiveLocationTrackingService`) +- Test: `test/utils/services/live_location_tracking/live_location_tracking_service_test.dart` (existing — extend fakes + add cases) + +**Interfaces:** +- Consumes: `ILiveTrackingStore` (Task 4), `IEntityNameResolver` (Task 5), `LastTrackingRecord`/`TrackingEndReason` (Task 3). +- Produces: on `start`, writes a `LastTrackingRecord` (endReason `interrupted`, resolved `targetName`); on end via `stop` writes `endReason: manual`, via max-duration writes `endReason: maxDuration`, both with final counts + last coordinates + `lastError`. No interface signature change to `ILiveLocationTrackingService`. + +- [ ] **Step 1: Extend the test fakes and add cases** + +In `test/utils/services/live_location_tracking/live_location_tracking_service_test.dart`, add fakes near the top: + +```dart +class FakeStore implements ILiveTrackingStore { + LastTrackingRecord? record; + int writeCount = 0; + + @override + Future read() async => record; + + @override + Future write(LastTrackingRecord r) async { + record = r; + writeCount++; + } + + @override + Future clear() async => record = null; +} + +class FakeNameResolver implements IEntityNameResolver { + String? name = 'My Tracker'; + + @override + Future resolveName(String entityType, String id) async => name; +} +``` + +Add imports: + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; +``` + +Update `setUp` to construct the service with the new deps: + +```dart + late FakeStore store; + late FakeNameResolver nameResolver; + + setUp(() { + location = FakeLocationService(); + remote = FakeRemote(); + store = FakeStore(); + nameResolver = FakeNameResolver(); + service = LiveLocationTrackingService( + locationService: location, + remote: remote, + logger: TbLogger(), + store: store, + nameResolver: nameResolver, + ); + }); +``` + +Add cases: + +```dart + test('start writes an interrupted record with the resolved name', () async { + await service.start(const LiveTrackingConfig(target: target)); + expect(store.record, isNotNull); + expect(store.record!.targetName, 'My Tracker'); + expect(store.record!.endReason, TrackingEndReason.interrupted); + expect(store.record!.endedAt, isNull); + }); + + test('stop updates the record with manual end reason and counts', () async { + await service.start(const LiveTrackingConfig(target: target)); + location.controller!.add(LocationSuccess(fix)); + await pumpEventQueue(); + + await service.stop(); + expect(store.record!.endReason, TrackingEndReason.manual); + expect(store.record!.endedAt, isNotNull); + expect(store.record!.fixCount, 1); + expect(store.record!.savedCount, 1); + expect(store.record!.lastLat, 1.0); + expect(store.record!.lastLng, 2.0); + }); + + test('max-duration end writes maxDuration reason', () { + fakeAsync((async) { + service.start( + const LiveTrackingConfig(target: target, maxDurationMinutes: 5), + ); + async.flushMicrotasks(); + async.elapse(const Duration(minutes: 5, seconds: 1)); + async.flushMicrotasks(); + expect(store.record!.endReason, TrackingEndReason.maxDuration); + }); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/utils/services/live_location_tracking/live_location_tracking_service_test.dart` +Expected: FAIL — constructor has no `store`/`nameResolver`. + +- [ ] **Step 3: Implement the service changes** + +In `live_location_tracking_service.dart`: + +Add imports: + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; +``` + +Extend the constructor and fields: + +```dart + LiveLocationTrackingService({ + required ILocationService locationService, + required ILiveTrackingRemote remote, + required TbLogger logger, + required ILiveTrackingStore store, + required IEntityNameResolver nameResolver, + this.backgroundConfig = const BackgroundTrackingConfig( + notificationTitle: 'ThingsBoard', + notificationText: 'Live location tracking is active', + ), + }) : _locationService = locationService, + _remote = remote, + _log = logger, + _store = store, + _nameResolver = nameResolver; + + final ILocationService _locationService; + final ILiveTrackingRemote _remote; + final TbLogger _log; + final ILiveTrackingStore _store; + final IEntityNameResolver _nameResolver; +``` + +Replace `start` so it seeds a record (endReason interrupted) and resolves the name: + +```dart + @override + Future start(LiveTrackingConfig config) async { + await stop(); + final startedAt = DateTime.now(); + _setSession( + LiveTrackingSession( + config: config, + status: LiveTrackingStatus.tracking, + startedAt: startedAt, + ), + ); + final name = await _nameResolver.resolveName( + config.target.entityType, + config.target.id, + ); + await _store.write( + LastTrackingRecord( + configJson: config.toJson(), + targetName: name, + startedAt: startedAt, + endReason: TrackingEndReason.interrupted, + ), + ); + await _writeStatusAttributes({ + 'gpsActive': true, + if (config.trackedBy != null) 'gpsTrackedBy': config.trackedBy, + }); + _subscribe(config); + final maxDuration = config.maxDurationMinutes; + if (maxDuration != null) { + _maxDurationTimer = Timer( + Duration(minutes: maxDuration), + () => _finish(TrackingEndReason.maxDuration), + ); + } + } +``` + +Replace `stop` to delegate to a private `_finish(manual)`, and add `_finish`: + +```dart + @override + Future stop() => _finish(TrackingEndReason.manual); + + Future _finish(TrackingEndReason reason) async { + _maxDurationTimer?.cancel(); + _maxDurationTimer = null; + _cancelSubscription(); + final current = _session; + if (current != null) { + await _writeStatusAttributes({'gpsActive': false}); + await _updateRecordOnEnd(current, reason); + _setSession(null); + } + } + + Future _updateRecordOnEnd( + LiveTrackingSession session, + TrackingEndReason reason, + ) async { + final existing = await _store.read(); + if (existing == null) { + return; + } + await _store.write( + existing.copyWith( + endedAt: DateTime.now(), + fixCount: session.fixCount, + savedCount: session.savedCount, + saveErrorCount: session.saveErrorCount, + lastLat: session.lastFix?.latitude, + lastLng: session.lastFix?.longitude, + lastError: session.lastError, + endReason: reason, + ), + ); + } +``` + +(Leave `pause`, `resume`, `_onFix`, `_saveFix`, `_pauseWithError`, `_subscribe`, `_writeStatusAttributes`, `_setSession`, `_cancelSubscription` unchanged.) + +- [ ] **Step 4: Update the locator wiring** + +In `lib/locator.dart`, update the `ILiveLocationTrackingService` registration to pass the new deps: + +```dart + ..registerLazySingleton( + () => LiveLocationTrackingService( + locationService: getIt(), + remote: getIt(), + logger: getIt(), + store: getIt(), + nameResolver: getIt(), + ), + ) +``` + +(Ensure the `ILiveTrackingStore` and `IEntityNameResolver` registrations from Tasks 4–5 appear **before** this one.) + +- [ ] **Step 5: Run tests, analyze, commit** + +```bash +flutter test test/utils/services/live_location_tracking/ +flutter analyze 2>&1 | grep -E "live_location_tracking_service|locator" ; echo "expect no output above" +dart format lib/utils/services/live_location_tracking/live_location_tracking_service.dart lib/locator.dart test/utils/services/live_location_tracking/live_location_tracking_service_test.dart +git add lib/utils/services/live_location_tracking/live_location_tracking_service.dart lib/locator.dart test/utils/services/live_location_tracking/live_location_tracking_service_test.dart +git commit -m "feat(location): persist last session with end reason and resolved name" +``` + +--- + +### Task 7: Flutter — name provider, `LiveTrackingPage` (active + idle + Start again), l10n + +**Files:** +- Modify: `lib/l10n/intl_en.arb` (append keys) +- Modify: `lib/modules/location_tracking/presentation/provider/live_tracking_provider.dart` (add name + record providers) +- Create: `lib/modules/location_tracking/presentation/view/live_tracking_page.dart` +- Delete: `lib/modules/location_tracking/presentation/view/live_tracking_session_page.dart` (superseded) +- Test: `test/modules/location_tracking/live_tracking_page_test.dart` + +**Interfaces:** +- Consumes: `liveTrackingProvider` (session), `ILiveTrackingStore` + `IEntityNameResolver` via `getIt`, `displayTargetName` (Task 5), `LiveTrackingConfig.fromJson` (Task 2), `LastTrackingRecord` (Task 3), current-user email. +- Produces: `LiveTrackingPage` widget; `targetNameProvider` (`FutureProvider.family`); `lastRecordProvider` (`FutureProvider`). Consumed by Task 8 (route). + +- [ ] **Step 1: Add l10n keys** + +In `lib/l10n/intl_en.arb`, change the current last entry `"liveTrackingLastError": "Last error"` to have a trailing comma and append before the closing `}`: + +```json + "liveTrackingLastError": "Last error", + "liveTrackingMenuTitle": "Live location tracking", + "liveTrackingNoRecord": "No active tracking and no recent session.", + "liveTrackingLastSession": "Last session", + "liveTrackingStartAgain": "Start again", + "liveTrackingEnded": "Ended", + "liveTrackingEndReason": "End reason", + "liveTrackingEndReasonManual": "Stopped manually", + "liveTrackingEndReasonMaxDuration": "Reached max duration", + "liveTrackingEndReasonInterrupted": "Interrupted" +``` + +Run: `flutter pub run intl_utils:generate` +Expected: `lib/generated/l10n.dart` gains the new getters. + +- [ ] **Step 2: Add providers** + +In `lib/modules/location_tracking/presentation/provider/live_tracking_provider.dart`, add imports: + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; +``` + +Add two providers at the bottom of the file: + +```dart +@riverpod +Future targetName( + Ref ref, { + required String entityType, + required String id, +}) => getIt().resolveName(entityType, id); + +@riverpod +Future lastRecord(Ref ref) => + getIt().read(); +``` + +Run: `flutter pub run build_runner build --delete-conflicting-outputs` +Expected: regenerates `live_tracking_provider.g.dart` with `targetNameProvider` and `lastRecordProvider`. + +- [ ] **Step 3: Write the failing widget test** + +Create `test/modules/location_tracking/live_tracking_page_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:thingsboard_app/generated/l10n.dart'; +import 'package:thingsboard_app/modules/location_tracking/presentation/view/live_tracking_page.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; + +class _FakeStore implements ILiveTrackingStore { + _FakeStore(this.record); + final LastTrackingRecord? record; + @override + Future read() async => record; + @override + Future write(LastTrackingRecord r) async {} + @override + Future clear() async {} +} + +class _FakeResolver implements IEntityNameResolver { + @override + Future resolveName(String entityType, String id) async => null; +} + +Widget _wrap() => const ProviderScope( + child: MaterialApp( + localizationsDelegates: [S.delegate], + home: LiveTrackingPage(), + ), +); + +void main() { + tearDown(() => GetIt.I.reset()); + + testWidgets('idle with no record shows the empty message', (tester) async { + GetIt.I.registerLazySingleton(() => _FakeStore(null)); + GetIt.I.registerLazySingleton(() => _FakeResolver()); + + await tester.pumpWidget(_wrap()); + await tester.pumpAndSettle(); + + expect(find.text('No active tracking and no recent session.'), findsOneWidget); + }); + + testWidgets('idle with a record shows Start again', (tester) async { + final record = LastTrackingRecord( + configJson: const { + 'target': {'entityType': 'DEVICE', 'id': 'd-1'}, + }, + startedAt: DateTime.fromMillisecondsSinceEpoch(0), + endedAt: DateTime.fromMillisecondsSinceEpoch(60000), + endReason: TrackingEndReason.manual, + targetName: 'My Tracker', + ); + GetIt.I.registerLazySingleton(() => _FakeStore(record)); + GetIt.I.registerLazySingleton(() => _FakeResolver()); + + await tester.pumpWidget(_wrap()); + await tester.pumpAndSettle(); + + expect(find.text('Start again'), findsOneWidget); + expect(find.text('My Tracker'), findsWidgets); + }); +} +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `flutter test test/modules/location_tracking/live_tracking_page_test.dart` +Expected: FAIL — `LiveTrackingPage` doesn't exist. + +- [ ] **Step 5: Implement `LiveTrackingPage`** + +Create `lib/modules/location_tracking/presentation/view/live_tracking_page.dart`. It renders one of three states. (The active-session details reuse the phase-1c layout; the Target row now uses `displayTargetName` fed by `targetNameProvider`.) + +```dart +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:thingsboard_app/generated/l10n.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/modules/location_tracking/presentation/provider/live_tracking_provider.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_display.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; +import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; + +class LiveTrackingPage extends ConsumerWidget { + const LiveTrackingPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final session = ref.watch(liveTrackingProvider).session; + return Scaffold( + appBar: AppBar(title: Text(S.of(context).liveTrackingSessionTitle)), + body: + session != null + ? _ActiveSession(session: session) + : _IdleView(), + ); + } +} + +class _ActiveSession extends ConsumerWidget { + const _ActiveSession({required this.session}); + + final LiveTrackingSession session; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tracking = session.status == LiveTrackingStatus.tracking; + final lastFix = session.lastFix; + final target = session.config.target; + final nameAsync = ref.watch( + targetNameProvider(entityType: target.entityType, id: target.id), + ); + final name = displayTargetName(nameAsync.valueOrNull, target); + return ListView( + children: [ + ListTile( + title: Text(S.of(context).liveTrackingTarget), + subtitle: Text(name), + ), + ListTile( + title: Text(S.of(context).liveTrackingStatus), + subtitle: Text( + tracking + ? S.of(context).liveTrackingActive + : S.of(context).liveTrackingPaused, + ), + ), + ListTile( + title: Text(S.of(context).liveTrackingStarted), + subtitle: Text(session.startedAt.toLocal().toString()), + ), + ListTile( + title: Text( + '${S.of(context).liveTrackingFixes}: ${session.fixCount} · ' + '${S.of(context).liveTrackingSaved}: ${session.savedCount} · ' + '${S.of(context).liveTrackingErrors}: ${session.saveErrorCount}', + ), + ), + if (lastFix != null) + ListTile( + title: Text(S.of(context).liveTrackingLastFix), + subtitle: Text( + '${lastFix.latitude.toStringAsFixed(6)}, ' + '${lastFix.longitude.toStringAsFixed(6)} ' + '(±${lastFix.accuracy.toStringAsFixed(0)} m)', + ), + ), + if (session.lastError != null) + ListTile( + title: Text(S.of(context).liveTrackingLastError), + subtitle: Text( + session.lastError!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () { + final notifier = ref.read(liveTrackingProvider.notifier); + tracking ? notifier.pause() : notifier.resume(); + }, + icon: Icon(tracking ? Icons.pause : Icons.play_arrow), + label: Text( + tracking + ? S.of(context).liveTrackingPause + : S.of(context).liveTrackingResume, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.icon( + onPressed: + () => ref.read(liveTrackingProvider.notifier).stop(), + icon: const Icon(Icons.stop), + label: Text(S.of(context).liveTrackingStop), + ), + ), + ], + ), + ), + ], + ); + } +} + +class _IdleView extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final recordAsync = ref.watch(lastRecordProvider); + return recordAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => Center(child: Text(S.of(context).liveTrackingNoRecord)), + data: (record) { + if (record == null) { + return Center(child: Text(S.of(context).liveTrackingNoRecord)); + } + return _LastSession(record: record); + }, + ); + } +} + +class _LastSession extends ConsumerWidget { + const _LastSession({required this.record}); + + final LastTrackingRecord record; + + String _endReasonLabel(BuildContext context) => switch (record.endReason) { + TrackingEndReason.manual => S.of(context).liveTrackingEndReasonManual, + TrackingEndReason.maxDuration => + S.of(context).liveTrackingEndReasonMaxDuration, + TrackingEndReason.interrupted => + S.of(context).liveTrackingEndReasonInterrupted, + }; + + Future _startAgain(WidgetRef ref) async { + // AuthUser.sub is the current user's email (phase-1c trackedBy semantics); + // re-derive it so a relaunch is attributed to whoever is logged in now. + final email = getIt().client.getAuthUser()?.sub; + final config = LiveTrackingConfig.fromJson({ + ...record.configJson, + if (email != null) 'trackedBy': email, + }); + await ref.read(liveTrackingProvider.notifier).startConfig(config); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final target = record.config.target; + final name = displayTargetName(record.targetName, target); + return ListView( + children: [ + ListTile( + title: Text(S.of(context).liveTrackingLastSession), + subtitle: Text(name), + ), + ListTile( + title: Text(S.of(context).liveTrackingStarted), + subtitle: Text(record.startedAt.toLocal().toString()), + ), + if (record.endedAt != null) + ListTile( + title: Text(S.of(context).liveTrackingEnded), + subtitle: Text(record.endedAt!.toLocal().toString()), + ), + ListTile( + title: Text(S.of(context).liveTrackingEndReason), + subtitle: Text(_endReasonLabel(context)), + ), + ListTile( + title: Text( + '${S.of(context).liveTrackingFixes}: ${record.fixCount} · ' + '${S.of(context).liveTrackingSaved}: ${record.savedCount} · ' + '${S.of(context).liveTrackingErrors}: ${record.saveErrorCount}', + ), + ), + if (record.lastLat != null && record.lastLng != null) + ListTile( + title: Text(S.of(context).liveTrackingLastFix), + subtitle: Text( + '${record.lastLat!.toStringAsFixed(6)}, ' + '${record.lastLng!.toStringAsFixed(6)}', + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: FilledButton.icon( + onPressed: () => _startAgain(ref), + icon: const Icon(Icons.play_arrow), + label: Text(S.of(context).liveTrackingStartAgain), + ), + ), + ], + ); + } +} +``` + +- [ ] **Step 6: Add `startConfig` to the provider notifier** + +In `live_tracking_provider.dart`, add a method to the `LiveTracking` class (used by "Start again"): + +```dart + Future startConfig(LiveTrackingConfig config) => + getIt().start(config); +``` + +Add import: + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +``` + +- [ ] **Step 7: Delete the old session page** + +```bash +git rm lib/modules/location_tracking/presentation/view/live_tracking_session_page.dart +``` + +(Task 8 updates the route + bar that referenced it.) + +- [ ] **Step 8: Run test, regen, analyze, commit** + +```bash +flutter pub run build_runner build --delete-conflicting-outputs +flutter test test/modules/location_tracking/live_tracking_page_test.dart +flutter analyze 2>&1 | grep -E "live_tracking_page|live_tracking_provider" ; echo "expect no output above" +dart format lib/modules/location_tracking/ test/modules/location_tracking/live_tracking_page_test.dart lib/l10n +git add lib/modules/location_tracking/ lib/l10n/intl_en.arb lib/generated/ test/modules/location_tracking/live_tracking_page_test.dart +git commit -m "feat(location): add Live tracking page with idle last-session and Start again" +``` + +> `trackedBy` uses `getAuthUser()?.sub` — confirmed as the current user's email in the Dart client's `AuthUser` (`user_models.dart`: `String sub;`, `String? userId;`). The value is only a label. + +--- + +### Task 8: Flutter — page mapping, route rename, spike removal, bar route update + +**Files:** +- Modify: `lib/utils/services/layouts/pages_layout.dart` (add enum value) +- Modify: `lib/modules/main/providers/navigation_helper.dart` (4 switches + localized title) +- Modify: `lib/config/routes/v2/routes_config/routes/location_tracking_routes.dart` (rename route, drop spike, point to new page) +- Modify: `lib/modules/more/more_page.dart` (remove `kDebugMode` spike entry + import) +- Modify: `lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart` (update `context.push`) +- Delete: `lib/modules/location_tracking/presentation/view/live_tracking_spike_page.dart` +- Test: `test/modules/location_tracking/navigation_helper_live_tracking_test.dart` + +**Interfaces:** +- Consumes: `Pages` enum; `PageLayout`. +- Produces: `Pages.live_location_tracking`; `NavigationHelper.getPath(...) == '/liveTracking'`, `getLabel(...) == 'Live location tracking'`, `getIcon(...) == Icons.my_location`; route constant `LocationTrackingRoutes.liveTracking = '/liveTracking'` builds `LiveTrackingPage`. + +- [ ] **Step 1: Write the failing test** + +Create `test/modules/location_tracking/navigation_helper_live_tracking_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/modules/main/providers/navigation_helper.dart'; +import 'package:thingsboard_app/utils/services/layouts/pages_layout.dart'; + +void main() { + const layout = PageLayout(id: Pages.live_location_tracking); + + test('parses LIVE_LOCATION_TRACKING from server string', () { + expect(pagesFromString('LIVE_LOCATION_TRACKING'), Pages.live_location_tracking); + }); + + test('maps to route, label and icon', () { + expect(NavigationHelper.getPath(layout), '/liveTracking'); + expect(NavigationHelper.getLabel(layout), 'Live location tracking'); + expect(NavigationHelper.getIcon(layout), Icons.my_location); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/modules/location_tracking/navigation_helper_live_tracking_test.dart` +Expected: FAIL — `Pages.live_location_tracking` doesn't exist. + +- [ ] **Step 3: Add the enum value** + +In `lib/utils/services/layouts/pages_layout.dart`, add to `Pages` before `undefined`: + +```dart + dashboards, + live_location_tracking, + undefined, +``` + +- [ ] **Step 4: Map it in NavigationHelper** + +In `lib/modules/main/providers/navigation_helper.dart`: + +`getLabel` — add before the `undefined`/`null` case: + +```dart + case Pages.live_location_tracking: + return 'Live location tracking'; +``` + +`getIcon` — add before the `undefined`/`null` case: + +```dart + case Pages.live_location_tracking: + return Icons.my_location; +``` + +`getPath` — add before the `undefined`/`null` case: + +```dart + case Pages.live_location_tracking: + return '/liveTracking'; +``` + +`getLocalizedTitle` — add a case in the `switch (id)`: + +```dart + case 'live_location_tracking': + return s.liveTrackingMenuTitle; +``` + +- [ ] **Step 5: Rename the route + drop the spike** + +Replace `lib/config/routes/v2/routes_config/routes/location_tracking_routes.dart` with: + +```dart +import 'package:go_router/go_router.dart'; +import 'package:thingsboard_app/modules/location_tracking/presentation/view/live_tracking_page.dart'; + +class LocationTrackingRoutes { + static const liveTracking = '/liveTracking'; +} + +final List locationTrackingRoutes = [ + GoRoute( + path: LocationTrackingRoutes.liveTracking, + builder: (context, state) { + return const LiveTrackingPage(); + }, + ), +]; +``` + +- [ ] **Step 6: Update the bar's push target** + +In `lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart`, change: + +```dart + onTap: () => context.push(LocationTrackingRoutes.liveTracking), +``` + +- [ ] **Step 7: Remove the debug spike menu entry** + +In `lib/modules/more/more_page.dart`, delete the `if (kDebugMode) MoreMenuItemWidget(... liveTrackingSpike ...)` block (the block at ~L85-96) and remove the now-unused `kDebugMode` import if nothing else uses it (check first: `grep -n kDebugMode lib/modules/more/more_page.dart`). + +- [ ] **Step 8: Delete the spike page** + +```bash +git rm lib/modules/location_tracking/presentation/view/live_tracking_spike_page.dart +``` + +- [ ] **Step 9: Run tests, analyze, commit** + +```bash +flutter test test/modules/location_tracking/ +flutter analyze 2>&1 | grep -E "navigation_helper|location_tracking_routes|more_page|live_tracking_bar|pages_layout" ; echo "expect no output above" +dart format lib/utils/services/layouts/pages_layout.dart lib/modules/main/providers/navigation_helper.dart lib/config/routes/v2/routes_config/routes/location_tracking_routes.dart lib/modules/more/more_page.dart lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart test/modules/location_tracking/navigation_helper_live_tracking_test.dart +git add lib/ test/modules/location_tracking/navigation_helper_live_tracking_test.dart +git commit -m "feat(location): map live tracking bundle page and remove debug spike" +``` + +--- + +### Task 9: Flutter — collapsed bar polish (full-width, pulsing icon) + +**Files:** +- Modify: `lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart` +- Test: `test/modules/location_tracking/live_tracking_bar_test.dart` (existing — add case) + +**Interfaces:** +- Consumes: `liveTrackingProvider` (session + hidden). No new produced interface. + +- [ ] **Step 1: Add the failing widget test** + +Add to `test/modules/location_tracking/live_tracking_bar_test.dart` a case asserting the collapsed bar is full-width and shows `gps_fixed` while tracking. (Match the existing test file's harness for providing a session with `hidden: true`; the existing file already builds the bar with an overridden provider — mirror it.) + +```dart + testWidgets('collapsed bar spans full width and shows gps_fixed when tracking', + (tester) async { + // Arrange: session active + hidden == true (reuse this file's existing + // helper that pumps LiveTrackingBar with a tracking session, then tap Hide + // or seed hidden=true as the existing tests do). + // Assert: + expect(find.byIcon(Icons.gps_fixed), findsOneWidget); + final material = tester.widget( + find.ancestor(of: find.byIcon(Icons.gps_fixed), matching: find.byType(Material)).first, + ); + expect(material.color, isNotNull); + final size = tester.getSize(find.byType(Material).first); + expect(size.width, tester.view.physicalSize.width / tester.view.devicePixelRatio); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/modules/location_tracking/live_tracking_bar_test.dart` +Expected: FAIL — collapsed bar is not full-width / not pulsing yet. + +- [ ] **Step 3: Implement the pulsing full-width collapsed bar** + +In `live_tracking_bar.dart`, add imports: + +```dart +import 'package:flutter_hooks/flutter_hooks.dart'; +``` + +Replace the `if (viewState.hidden) { return Material(...); }` block with a call to a new widget, and add that widget at the bottom of the file: + +```dart + if (viewState.hidden) { + return _CollapsedBar(tracking: tracking); + } +``` + +```dart +class _CollapsedBar extends HookConsumerWidget { + const _CollapsedBar({required this.tracking}); + + final bool tracking; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = Theme.of(context).colorScheme; + final controller = useAnimationController( + duration: const Duration(milliseconds: 900), + ); + useEffect(() { + if (tracking) { + controller.repeat(reverse: true); + } else { + controller.stop(); + controller.value = 1; + } + return null; + }, [tracking]); + + return Material( + color: colors.primaryContainer, + child: InkWell( + onTap: () => ref.read(liveTrackingProvider.notifier).show(), + child: SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Center( + child: FadeTransition( + opacity: Tween(begin: 0.35, end: 1).animate(controller), + child: Icon( + tracking ? Icons.gps_fixed : Icons.gps_off, + size: 18, + color: colors.onPrimaryContainer, + ), + ), + ), + ), + ), + ), + ); + } +} +``` + +Change the outer `LiveTrackingBar` from `ConsumerWidget` to `ConsumerWidget` (unchanged) — only the collapsed branch is extracted. Keep the expanded `Material(...)` return as-is. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/modules/location_tracking/live_tracking_bar_test.dart` +Expected: PASS. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +flutter analyze 2>&1 | grep live_tracking_bar ; echo "expect no output above" +dart format lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart test/modules/location_tracking/live_tracking_bar_test.dart +git add lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart test/modules/location_tracking/live_tracking_bar_test.dart +git commit -m "feat(location): full-width pulsing collapsed tracking bar" +``` + +--- + +### Task 10: Flutter — stop tracking and clear the record on logout + +**Files:** +- Modify: `lib/core/context/tb_context.dart` (`logout`, ~L299-314) + +**Interfaces:** +- Consumes: `ILiveLocationTrackingService`, `ILiveTrackingStore` via `getIt`. + +- [ ] **Step 1: Add the teardown to logout** + +In `lib/core/context/tb_context.dart`, inside `logout(...)`, before `await tbClient.logout(...)`, add: + +```dart + await getIt().stop(); + await getIt().clear(); +``` + +Add imports if not present: + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +``` + +- [ ] **Step 2: Analyze, format, commit** + +```bash +flutter analyze 2>&1 | grep tb_context ; echo "expect no output above" +dart format lib/core/context/tb_context.dart +git add lib/core/context/tb_context.dart +git commit -m "feat(location): stop tracking and clear last-session record on logout" +``` + +--- + +### Task 11: End-to-end smoke test (manual, real Android device) + +**Files:** none (verification only). + +- [ ] **Step 1: Stack** — local TB CE backend built with the Task 1 backend change; `yarn start` ui-ngx with the Task 1 model change; Flutter debug build on the Android 16 phone. In **Mobile center → Bundles → Layout**, confirm "Live location tracking" appears in the page list and is **off by default**; toggle it **on** and save. + +- [ ] **Step 2: Menu entry** — reopen the app (re-login so the bundle reloads). "Live location tracking" appears in the More menu with the `my_location` icon; the debug "GPS tracking spike" entry is gone. + +- [ ] **Step 3: Friendly name** — start tracking from a dashboard action targeting a named device; open the page via the bar → the Target row shows the device **name** (not `DEVICE `). Kill network briefly → falls back to `DEVICE · ` without error. + +- [ ] **Step 4: Idle + Start again** — stop tracking; open the page from the menu → shows the **last session** summary (name, ended, end reason "Stopped manually", counts, last coordinates) + **Start again**. Tap it → a new session starts for the same entity, `gpsActive=true`, `gpsTrackedBy` = the currently logged-in user. + +- [ ] **Step 5: End reasons** — start with max duration 1 min → after auto-stop the idle page shows end reason "Reached max duration". Force-kill the app mid-session, relaunch, open the page → shows "Interrupted" with Start again still available. + +- [ ] **Step 6: Collapsed bar** — Hide the bar → it collapses to a **full-width** purple bar with a **pulsing** `gps_fixed` icon while tracking; pause → static `gps_off`; tap → expands. + +- [ ] **Step 7: Logout** — log out while a session is active → tracking stops (`gpsActive=false`); log back in → the idle page shows "No active tracking and no recent session." (record cleared). From 5975a398dba840ae7805831e2d705782809d5ec5 Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 22 Jul 2026 18:08:50 +0300 Subject: [PATCH 18/40] feat(location): add LiveTrackingConfig.toJson for session persistence --- .../model/live_tracking_config.dart | 23 ++++++++++++++ .../live_tracking_config_test.dart | 31 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/lib/utils/services/live_location_tracking/model/live_tracking_config.dart b/lib/utils/services/live_location_tracking/model/live_tracking_config.dart index 66f01048..b6bca443 100644 --- a/lib/utils/services/live_location_tracking/model/live_tracking_config.dart +++ b/lib/utils/services/live_location_tracking/model/live_tracking_config.dart @@ -16,6 +16,8 @@ class LiveTrackingTarget { final String entityType; final String id; + + Map toJson() => {'entityType': entityType, 'id': id}; } /// Fully-resolved live tracking session config received from the dashboard @@ -76,4 +78,25 @@ class LiveTrackingConfig { 'LOW' => LocationAccuracyLevel.low, _ => LocationAccuracyLevel.balanced, }; + + Map toJson() => { + 'target': target.toJson(), + 'latitudeKey': latitudeKey, + 'longitudeKey': longitudeKey, + 'includeMetadata': includeMetadata, + 'mirrorToAttributes': mirrorToAttributes, + 'accuracy': _accuracyToString(accuracy), + 'distanceFilterMeters': distanceFilterMeters, + 'intervalSeconds': intervalSeconds, + 'maxDurationMinutes': maxDurationMinutes, + 'writeStatusAttributes': writeStatusAttributes, + 'trackedBy': trackedBy, + }; + + static String _accuracyToString(LocationAccuracyLevel level) => + switch (level) { + LocationAccuracyLevel.high => 'HIGH', + LocationAccuracyLevel.low => 'LOW', + LocationAccuracyLevel.balanced => 'BALANCED', + }; } diff --git a/test/utils/services/live_location_tracking/live_tracking_config_test.dart b/test/utils/services/live_location_tracking/live_tracking_config_test.dart index 5e72838e..0ba8c171 100644 --- a/test/utils/services/live_location_tracking/live_tracking_config_test.dart +++ b/test/utils/services/live_location_tracking/live_tracking_config_test.dart @@ -73,4 +73,35 @@ void main() { throwsFormatException, ); }); + + test('toJson round-trips through fromJson', () { + const original = LiveTrackingConfig( + target: LiveTrackingTarget(entityType: 'DEVICE', id: 'abc-123'), + latitudeKey: 'lat', + longitudeKey: 'lng', + includeMetadata: true, + mirrorToAttributes: true, + accuracy: LocationAccuracyLevel.high, + distanceFilterMeters: 25, + intervalSeconds: 60, + maxDurationMinutes: 120, + writeStatusAttributes: false, + trackedBy: 'user@example.com', + ); + + final restored = LiveTrackingConfig.fromJson(original.toJson()); + + expect(restored.target.entityType, 'DEVICE'); + expect(restored.target.id, 'abc-123'); + expect(restored.latitudeKey, 'lat'); + expect(restored.longitudeKey, 'lng'); + expect(restored.includeMetadata, true); + expect(restored.mirrorToAttributes, true); + expect(restored.accuracy, LocationAccuracyLevel.high); + expect(restored.distanceFilterMeters, 25); + expect(restored.intervalSeconds, 60); + expect(restored.maxDurationMinutes, 120); + expect(restored.writeStatusAttributes, false); + expect(restored.trackedBy, 'user@example.com'); + }); } From 58493b30c44878dbf7c3d190bc42d0d6f6b3f354 Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 22 Jul 2026 18:12:13 +0300 Subject: [PATCH 19/40] feat(location): add LastTrackingRecord model --- .../model/last_tracking_record.dart | 102 ++++++++++++++++++ .../last_tracking_record_test.dart | 47 ++++++++ 2 files changed, 149 insertions(+) create mode 100644 lib/utils/services/live_location_tracking/model/last_tracking_record.dart create mode 100644 test/utils/services/live_location_tracking/last_tracking_record_test.dart diff --git a/lib/utils/services/live_location_tracking/model/last_tracking_record.dart b/lib/utils/services/live_location_tracking/model/last_tracking_record.dart new file mode 100644 index 00000000..bcb03c8a --- /dev/null +++ b/lib/utils/services/live_location_tracking/model/last_tracking_record.dart @@ -0,0 +1,102 @@ +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; + +enum TrackingEndReason { manual, maxDuration, interrupted } + +TrackingEndReason _endReasonFromString(String? value) => + TrackingEndReason.values.firstWhere( + (e) => e.name == value, + orElse: () => TrackingEndReason.interrupted, + ); + +/// Snapshot of the most recent tracking session, persisted so the idle page +/// can show it and relaunch it ("Start again"). Written at session start and +/// updated at session end. +class LastTrackingRecord { + const LastTrackingRecord({ + required this.configJson, + required this.startedAt, + required this.endReason, + this.targetName, + this.endedAt, + this.fixCount = 0, + this.savedCount = 0, + this.saveErrorCount = 0, + this.lastLat, + this.lastLng, + this.lastError, + }); + + factory LastTrackingRecord.fromJson(Map json) => + LastTrackingRecord( + configJson: Map.from(json['configJson'] as Map), + targetName: json['targetName'] as String?, + startedAt: DateTime.fromMillisecondsSinceEpoch( + (json['startedAt'] as num).toInt(), + ), + endedAt: + json['endedAt'] == null + ? null + : DateTime.fromMillisecondsSinceEpoch( + (json['endedAt'] as num).toInt(), + ), + fixCount: (json['fixCount'] as num?)?.toInt() ?? 0, + savedCount: (json['savedCount'] as num?)?.toInt() ?? 0, + saveErrorCount: (json['saveErrorCount'] as num?)?.toInt() ?? 0, + lastLat: (json['lastLat'] as num?)?.toDouble(), + lastLng: (json['lastLng'] as num?)?.toDouble(), + lastError: json['lastError'] as String?, + endReason: _endReasonFromString(json['endReason'] as String?), + ); + + final Map configJson; + final String? targetName; + final DateTime startedAt; + final DateTime? endedAt; + final int fixCount; + final int savedCount; + final int saveErrorCount; + final double? lastLat; + final double? lastLng; + final String? lastError; + final TrackingEndReason endReason; + + LiveTrackingConfig get config => LiveTrackingConfig.fromJson(configJson); + + Map toJson() => { + 'configJson': configJson, + 'targetName': targetName, + 'startedAt': startedAt.millisecondsSinceEpoch, + 'endedAt': endedAt?.millisecondsSinceEpoch, + 'fixCount': fixCount, + 'savedCount': savedCount, + 'saveErrorCount': saveErrorCount, + 'lastLat': lastLat, + 'lastLng': lastLng, + 'lastError': lastError, + 'endReason': endReason.name, + }; + + LastTrackingRecord copyWith({ + String? targetName, + DateTime? endedAt, + int? fixCount, + int? savedCount, + int? saveErrorCount, + double? lastLat, + double? lastLng, + String? lastError, + TrackingEndReason? endReason, + }) => LastTrackingRecord( + configJson: configJson, + targetName: targetName ?? this.targetName, + startedAt: startedAt, + endedAt: endedAt ?? this.endedAt, + fixCount: fixCount ?? this.fixCount, + savedCount: savedCount ?? this.savedCount, + saveErrorCount: saveErrorCount ?? this.saveErrorCount, + lastLat: lastLat ?? this.lastLat, + lastLng: lastLng ?? this.lastLng, + lastError: lastError ?? this.lastError, + endReason: endReason ?? this.endReason, + ); +} diff --git a/test/utils/services/live_location_tracking/last_tracking_record_test.dart b/test/utils/services/live_location_tracking/last_tracking_record_test.dart new file mode 100644 index 00000000..17d90b95 --- /dev/null +++ b/test/utils/services/live_location_tracking/last_tracking_record_test.dart @@ -0,0 +1,47 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; + +void main() { + final json = { + 'configJson': { + 'target': {'entityType': 'DEVICE', 'id': 'd-1'}, + }, + 'targetName': 'My Tracker', + 'startedAt': 1720000000000, + 'endedAt': 1720000600000, + 'fixCount': 12, + 'savedCount': 11, + 'saveErrorCount': 1, + 'lastLat': 1.5, + 'lastLng': 2.5, + 'lastError': 'boom', + 'endReason': 'maxDuration', + }; + + test('fromJson/toJson round-trips', () { + final record = LastTrackingRecord.fromJson(json); + expect(record.targetName, 'My Tracker'); + expect(record.startedAt.millisecondsSinceEpoch, 1720000000000); + expect(record.endedAt?.millisecondsSinceEpoch, 1720000600000); + expect(record.fixCount, 12); + expect(record.savedCount, 11); + expect(record.saveErrorCount, 1); + expect(record.lastLat, 1.5); + expect(record.lastLng, 2.5); + expect(record.lastError, 'boom'); + expect(record.endReason, TrackingEndReason.maxDuration); + expect(record.config.target.id, 'd-1'); + expect( + LastTrackingRecord.fromJson(record.toJson()).toJson(), + record.toJson(), + ); + }); + + test('unknown endReason falls back to interrupted', () { + final record = LastTrackingRecord.fromJson({ + ...json, + 'endReason': 'nonsense', + }); + expect(record.endReason, TrackingEndReason.interrupted); + }); +} From 3748df42a62a08f0fdb3751dc447cba5358b25b3 Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 22 Jul 2026 18:18:46 +0300 Subject: [PATCH 20/40] feat(location): add ILiveTrackingStore for last-session persistence --- lib/constants/database_keys.dart | 1 + lib/locator.dart | 5 ++ .../i_live_tracking_store.dart | 11 ++++ .../live_tracking_store.dart | 53 ++++++++++++++++ .../live_tracking_store_test.dart | 63 +++++++++++++++++++ 5 files changed, 133 insertions(+) create mode 100644 lib/utils/services/live_location_tracking/i_live_tracking_store.dart create mode 100644 lib/utils/services/live_location_tracking/live_tracking_store.dart create mode 100644 test/utils/services/live_location_tracking/live_tracking_store_test.dart diff --git a/lib/constants/database_keys.dart b/lib/constants/database_keys.dart index 856a2273..d1e306aa 100644 --- a/lib/constants/database_keys.dart +++ b/lib/constants/database_keys.dart @@ -2,4 +2,5 @@ abstract final class DatabaseKeys { static const thingsBoardApiEndpointKey = 'thingsBoardApiEndpoint'; static const initialAppLink = 'initialAppLink'; static const selectedRegion = 'selectedRegion'; + static const liveTrackingLastRecord = 'live_tracking_last_record'; } diff --git a/lib/locator.dart b/lib/locator.dart index 0a38a1ec..bdd2ea7d 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -16,8 +16,10 @@ import 'package:thingsboard_app/utils/services/layouts/i_layout_service.dart'; import 'package:thingsboard_app/utils/services/layouts/layout_service.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_remote.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/live_location_tracking_service.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_remote.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_store.dart'; import 'package:thingsboard_app/utils/services/loading_service/i_loading_service.dart'; import 'package:thingsboard_app/utils/services/loading_service/loading_service.dart'; import 'package:thingsboard_app/utils/services/local_database/i_local_database_service.dart'; @@ -77,6 +79,9 @@ Future setUpRootDependencies() async { logger: getIt(), ), ) + ..registerLazySingleton( + () => LiveTrackingStore(storage: getIt(), logger: getIt()), + ) // ..registerLazySingleton(() => TbContext()) ..registerSingletonAsync(() async { final client = TbClientService(); diff --git a/lib/utils/services/live_location_tracking/i_live_tracking_store.dart b/lib/utils/services/live_location_tracking/i_live_tracking_store.dart new file mode 100644 index 00000000..75bd9194 --- /dev/null +++ b/lib/utils/services/live_location_tracking/i_live_tracking_store.dart @@ -0,0 +1,11 @@ +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; + +/// Persists the single most-recent tracking session so the idle page can show +/// and relaunch it. Device-global; cleared on logout. +abstract interface class ILiveTrackingStore { + Future read(); + + Future write(LastTrackingRecord record); + + Future clear(); +} diff --git a/lib/utils/services/live_location_tracking/live_tracking_store.dart b/lib/utils/services/live_location_tracking/live_tracking_store.dart new file mode 100644 index 00000000..bbf297d7 --- /dev/null +++ b/lib/utils/services/live_location_tracking/live_tracking_store.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; + +import 'package:thingsboard_app/constants/database_keys.dart'; +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/thingsboard_client.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; + +class LiveTrackingStore implements ILiveTrackingStore { + LiveTrackingStore({required TbStorage storage, required TbLogger logger}) + : _storage = storage, + _log = logger; + + final TbStorage _storage; + final TbLogger _log; + + @override + Future read() async { + try { + final raw = await _storage.getItem(DatabaseKeys.liveTrackingLastRecord); + if (raw is! String) { + return null; + } + return LastTrackingRecord.fromJson( + Map.from(jsonDecode(raw) as Map), + ); + } catch (e, s) { + _log.error('LiveTrackingStore.read failed', e, s); + return null; + } + } + + @override + Future write(LastTrackingRecord record) async { + try { + await _storage.setItem( + DatabaseKeys.liveTrackingLastRecord, + jsonEncode(record.toJson()), + ); + } catch (e, s) { + _log.error('LiveTrackingStore.write failed', e, s); + } + } + + @override + Future clear() async { + try { + await _storage.deleteItem(DatabaseKeys.liveTrackingLastRecord); + } catch (e, s) { + _log.error('LiveTrackingStore.clear failed', e, s); + } + } +} diff --git a/test/utils/services/live_location_tracking/live_tracking_store_test.dart b/test/utils/services/live_location_tracking/live_tracking_store_test.dart new file mode 100644 index 00000000..c7cf4b80 --- /dev/null +++ b/test/utils/services/live_location_tracking/live_tracking_store_test.dart @@ -0,0 +1,63 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/thingsboard_client.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; + +class FakeStorage implements TbStorage { + final _map = {}; + + @override + Future getItem(String key, {dynamic defaultValue}) async => + _map[key] ?? defaultValue; + + @override + Future setItem(String key, dynamic value) async => _map[key] = value; + + @override + Future deleteItem(String key) async => _map.remove(key); + + @override + Future containsKey(String key) async => _map.containsKey(key); +} + +void main() { + late FakeStorage storage; + late LiveTrackingStore store; + + setUp(() { + storage = FakeStorage(); + store = LiveTrackingStore(storage: storage, logger: TbLogger()); + }); + + final record = LastTrackingRecord( + configJson: const { + 'target': {'entityType': 'DEVICE', 'id': 'd-1'}, + }, + startedAt: DateTime.fromMillisecondsSinceEpoch(1720000000000), + endReason: TrackingEndReason.interrupted, + targetName: 'My Tracker', + ); + + test('read returns null when nothing stored', () async { + expect(await store.read(), isNull); + }); + + test('write then read round-trips', () async { + await store.write(record); + final read = await store.read(); + expect(read?.targetName, 'My Tracker'); + expect(read?.config.target.id, 'd-1'); + }); + + test('clear removes the record', () async { + await store.write(record); + await store.clear(); + expect(await store.read(), isNull); + }); + + test('read returns null on corrupt json', () async { + await storage.setItem('live_tracking_last_record', '{not valid'); + expect(await store.read(), isNull); + }); +} From cc99f8219e4e68e5fd74f100e74fa24d80d24b3b Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 22 Jul 2026 18:28:29 +0300 Subject: [PATCH 21/40] feat(location): add entity name resolver and display-name fallback --- lib/locator.dart | 5 +++ lib/utils/services/entity_query_api.dart | 29 ++++++++++++++++ .../entity_name_resolver.dart | 33 +++++++++++++++++++ .../i_entity_name_resolver.dart | 5 +++ .../live_tracking_display.dart | 11 +++++++ .../live_tracking_display_test.dart | 22 +++++++++++++ 6 files changed, 105 insertions(+) create mode 100644 lib/utils/services/live_location_tracking/entity_name_resolver.dart create mode 100644 lib/utils/services/live_location_tracking/i_entity_name_resolver.dart create mode 100644 lib/utils/services/live_location_tracking/live_tracking_display.dart create mode 100644 test/utils/services/live_location_tracking/live_tracking_display_test.dart diff --git a/lib/locator.dart b/lib/locator.dart index bdd2ea7d..02a1027a 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -14,6 +14,8 @@ import 'package:thingsboard_app/utils/services/firebase/firebase_service.dart'; import 'package:thingsboard_app/utils/services/firebase/i_firebase_service.dart'; import 'package:thingsboard_app/utils/services/layouts/i_layout_service.dart'; import 'package:thingsboard_app/utils/services/layouts/layout_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_remote.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; @@ -82,6 +84,9 @@ Future setUpRootDependencies() async { ..registerLazySingleton( () => LiveTrackingStore(storage: getIt(), logger: getIt()), ) + ..registerLazySingleton( + () => EntityNameResolver(clientService: getIt(), logger: getIt()), + ) // ..registerLazySingleton(() => TbContext()) ..registerSingletonAsync(() async { final client = TbClientService(); diff --git a/lib/utils/services/entity_query_api.dart b/lib/utils/services/entity_query_api.dart index 6f4318a6..b8d55553 100644 --- a/lib/utils/services/entity_query_api.dart +++ b/lib/utils/services/entity_query_api.dart @@ -125,6 +125,35 @@ abstract class EntityQueryApi { return response.data ?? 0; } + static EntityDataQuery createEntityNameQuery(String entityType, String id) { + return EntityDataQuery( + (b) => + b + ..entityFilter = SingleEntityFilter( + (b) => + b + ..type = 'singleEntity' + ..singleEntity = + AliasEntityId( + (b) => + b + ..entityType = EntityType.valueOf(entityType) + ..id = id, + ).toBuilder(), + ) + ..entityFields = + BuiltList([ + EntityKey( + (b) => + b + ..type = EntityKeyType.ENTITY_FIELD + ..key = 'name', + ), + ]).toBuilder() + ..pageLink = EntityDataPageLink((b) => b..pageSize = 1).toBuilder(), + ); + } + static EntityDataQuery createDefaultDeviceQuery({ int pageSize = 20, String? searchText, diff --git a/lib/utils/services/live_location_tracking/entity_name_resolver.dart b/lib/utils/services/live_location_tracking/entity_name_resolver.dart new file mode 100644 index 00000000..b5158228 --- /dev/null +++ b/lib/utils/services/live_location_tracking/entity_name_resolver.dart @@ -0,0 +1,33 @@ +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/utils/services/entity_query_api.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; + +class EntityNameResolver implements IEntityNameResolver { + EntityNameResolver({ + required ITbClientService clientService, + required TbLogger logger, + }) : _clientService = clientService, + _log = logger; + + final ITbClientService _clientService; + final TbLogger _log; + + @override + Future resolveName(String entityType, String id) async { + try { + final query = EntityQueryApi.createEntityNameQuery(entityType, id); + final response = await _clientService.client + .getEntityQueryControllerApi() + .findEntityDataByQuery(entityDataQuery: query); + final data = response.data?.data; + if (data == null || data.isEmpty) { + return null; + } + return data.first.field('name'); + } catch (e, s) { + _log.error('EntityNameResolver.resolveName failed', e, s); + return null; + } + } +} diff --git a/lib/utils/services/live_location_tracking/i_entity_name_resolver.dart b/lib/utils/services/live_location_tracking/i_entity_name_resolver.dart new file mode 100644 index 00000000..4cba40f5 --- /dev/null +++ b/lib/utils/services/live_location_tracking/i_entity_name_resolver.dart @@ -0,0 +1,5 @@ +/// Resolves an entity's display name from its type + id. Returns null on any +/// failure (offline, deleted entity, unknown type) so callers fall back. +abstract interface class IEntityNameResolver { + Future resolveName(String entityType, String id); +} diff --git a/lib/utils/services/live_location_tracking/live_tracking_display.dart b/lib/utils/services/live_location_tracking/live_tracking_display.dart new file mode 100644 index 00000000..af3aebef --- /dev/null +++ b/lib/utils/services/live_location_tracking/live_tracking_display.dart @@ -0,0 +1,11 @@ +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; + +/// Human-friendly label for a tracking target: the resolved entity name when +/// available, otherwise ` · `. +String displayTargetName(String? resolved, LiveTrackingTarget target) { + if (resolved != null && resolved.trim().isNotEmpty) { + return resolved; + } + final shortId = target.id.length > 8 ? target.id.substring(0, 8) : target.id; + return '${target.entityType} · $shortId'; +} diff --git a/test/utils/services/live_location_tracking/live_tracking_display_test.dart b/test/utils/services/live_location_tracking/live_tracking_display_test.dart new file mode 100644 index 00000000..2e9c1d99 --- /dev/null +++ b/test/utils/services/live_location_tracking/live_tracking_display_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_display.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; + +void main() { + const target = LiveTrackingTarget( + entityType: 'DEVICE', + id: 'f3eda640-42e8-11f1-af6c-63e319b36637', + ); + + test('uses resolved name when present', () { + expect(displayTargetName('My Tracker', target), 'My Tracker'); + }); + + test('falls back to type and short id when name is null', () { + expect(displayTargetName(null, target), 'DEVICE · f3eda640'); + }); + + test('falls back when name is empty/whitespace', () { + expect(displayTargetName(' ', target), 'DEVICE · f3eda640'); + }); +} From 97bfceae7c7bf061cb3dbde00f25a71451f485fd Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 22 Jul 2026 18:33:32 +0300 Subject: [PATCH 22/40] feat(location): persist last session with end reason and resolved name --- lib/locator.dart | 14 ++-- .../live_location_tracking_service.dart | 61 +++++++++++++++-- .../live_location_tracking_service_test.dart | 67 +++++++++++++++++++ 3 files changed, 131 insertions(+), 11 deletions(-) diff --git a/lib/locator.dart b/lib/locator.dart index 02a1027a..3827b7c9 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -74,19 +74,21 @@ Future setUpRootDependencies() async { ..registerLazySingleton( () => LiveTrackingRemote(clientService: getIt()), ) + ..registerLazySingleton( + () => LiveTrackingStore(storage: getIt(), logger: getIt()), + ) + ..registerLazySingleton( + () => EntityNameResolver(clientService: getIt(), logger: getIt()), + ) ..registerLazySingleton( () => LiveLocationTrackingService( locationService: getIt(), remote: getIt(), logger: getIt(), + store: getIt(), + nameResolver: getIt(), ), ) - ..registerLazySingleton( - () => LiveTrackingStore(storage: getIt(), logger: getIt()), - ) - ..registerLazySingleton( - () => EntityNameResolver(clientService: getIt(), logger: getIt()), - ) // ..registerLazySingleton(() => TbContext()) ..registerSingletonAsync(() async { final client = TbClientService(); diff --git a/lib/utils/services/live_location_tracking/live_location_tracking_service.dart b/lib/utils/services/live_location_tracking/live_location_tracking_service.dart index 621e7ce3..f910340b 100644 --- a/lib/utils/services/live_location_tracking/live_location_tracking_service.dart +++ b/lib/utils/services/live_location_tracking/live_location_tracking_service.dart @@ -1,8 +1,11 @@ import 'dart:async'; import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_remote.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; @@ -15,6 +18,8 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { required ILocationService locationService, required ILiveTrackingRemote remote, required TbLogger logger, + required ILiveTrackingStore store, + required IEntityNameResolver nameResolver, // Android notification strings are OS-level, set once at construction; // English defaults are acceptable for v1 (the locator can later pass // localized strings without touching this class). @@ -24,11 +29,15 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { ), }) : _locationService = locationService, _remote = remote, - _log = logger; + _log = logger, + _store = store, + _nameResolver = nameResolver; final ILocationService _locationService; final ILiveTrackingRemote _remote; final TbLogger _log; + final ILiveTrackingStore _store; + final IEntityNameResolver _nameResolver; final BackgroundTrackingConfig backgroundConfig; final _sessionController = StreamController.broadcast(); @@ -45,11 +54,24 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { @override Future start(LiveTrackingConfig config) async { await stop(); + final startedAt = DateTime.now(); _setSession( LiveTrackingSession( config: config, status: LiveTrackingStatus.tracking, - startedAt: DateTime.now(), + startedAt: startedAt, + ), + ); + final name = await _nameResolver.resolveName( + config.target.entityType, + config.target.id, + ); + await _store.write( + LastTrackingRecord( + configJson: config.toJson(), + targetName: name, + startedAt: startedAt, + endReason: TrackingEndReason.interrupted, ), ); await _writeStatusAttributes({ @@ -59,21 +81,50 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { _subscribe(config); final maxDuration = config.maxDurationMinutes; if (maxDuration != null) { - _maxDurationTimer = Timer(Duration(minutes: maxDuration), stop); + _maxDurationTimer = Timer( + Duration(minutes: maxDuration), + () => _finish(TrackingEndReason.maxDuration), + ); } } @override - Future stop() async { + Future stop() => _finish(TrackingEndReason.manual); + + Future _finish(TrackingEndReason reason) async { _maxDurationTimer?.cancel(); _maxDurationTimer = null; _cancelSubscription(); - if (_session != null) { + final current = _session; + if (current != null) { await _writeStatusAttributes({'gpsActive': false}); + await _updateRecordOnEnd(current, reason); _setSession(null); } } + Future _updateRecordOnEnd( + LiveTrackingSession session, + TrackingEndReason reason, + ) async { + final existing = await _store.read(); + if (existing == null) { + return; + } + await _store.write( + existing.copyWith( + endedAt: DateTime.now(), + fixCount: session.fixCount, + savedCount: session.savedCount, + saveErrorCount: session.saveErrorCount, + lastLat: session.lastFix?.latitude, + lastLng: session.lastFix?.longitude, + lastError: session.lastError, + endReason: reason, + ), + ); + } + @override Future pause() async { final current = _session; diff --git a/test/utils/services/live_location_tracking/live_location_tracking_service_test.dart b/test/utils/services/live_location_tracking/live_location_tracking_service_test.dart index b6038a41..05f2fb68 100644 --- a/test/utils/services/live_location_tracking/live_location_tracking_service_test.dart +++ b/test/utils/services/live_location_tracking/live_location_tracking_service_test.dart @@ -3,8 +3,11 @@ import 'dart:async'; import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_remote.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; @@ -37,6 +40,30 @@ class FakeLocationService implements ILocationService { Future openLocationSettings() async => true; } +class FakeStore implements ILiveTrackingStore { + LastTrackingRecord? record; + int writeCount = 0; + + @override + Future read() async => record; + + @override + Future write(LastTrackingRecord r) async { + record = r; + writeCount++; + } + + @override + Future clear() async => record = null; +} + +class FakeNameResolver implements IEntityNameResolver { + String? name = 'My Tracker'; + + @override + Future resolveName(String entityType, String id) async => name; +} + class FakeRemote implements ILiveTrackingRemote { final telemetryCalls = <(LiveTrackingTarget, int, Map)>[]; final attributeCalls = <(LiveTrackingTarget, Map)>[]; @@ -77,15 +104,21 @@ void main() { late FakeLocationService location; late FakeRemote remote; + late FakeStore store; + late FakeNameResolver nameResolver; late LiveLocationTrackingService service; setUp(() { location = FakeLocationService(); remote = FakeRemote(); + store = FakeStore(); + nameResolver = FakeNameResolver(); service = LiveLocationTrackingService( locationService: location, remote: remote, logger: TbLogger(), + store: store, + nameResolver: nameResolver, ); }); @@ -249,4 +282,38 @@ void main() { expect(service.session, isNull); }); }); + + test('start writes an interrupted record with the resolved name', () async { + await service.start(const LiveTrackingConfig(target: target)); + expect(store.record, isNotNull); + expect(store.record!.targetName, 'My Tracker'); + expect(store.record!.endReason, TrackingEndReason.interrupted); + expect(store.record!.endedAt, isNull); + }); + + test('stop updates the record with manual end reason and counts', () async { + await service.start(const LiveTrackingConfig(target: target)); + location.controller!.add(LocationSuccess(fix)); + await pumpEventQueue(); + + await service.stop(); + expect(store.record!.endReason, TrackingEndReason.manual); + expect(store.record!.endedAt, isNotNull); + expect(store.record!.fixCount, 1); + expect(store.record!.savedCount, 1); + expect(store.record!.lastLat, 1.0); + expect(store.record!.lastLng, 2.0); + }); + + test('max-duration end writes maxDuration reason', () { + fakeAsync((async) { + service.start( + const LiveTrackingConfig(target: target, maxDurationMinutes: 5), + ); + async.flushMicrotasks(); + async.elapse(const Duration(minutes: 5, seconds: 1)); + async.flushMicrotasks(); + expect(store.record!.endReason, TrackingEndReason.maxDuration); + }); + }); } From aaa436d66f0f81a92b782bef9e42c99a5034eb7c Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 22 Jul 2026 18:46:58 +0300 Subject: [PATCH 23/40] feat(location): add Live tracking page with idle last-session and Start again --- lib/generated/intl/messages_en.dart | 23 ++ lib/generated/l10n.dart | 85 ++++++++ lib/l10n/intl_en.arb | 11 +- .../provider/live_tracking_provider.dart | 19 ++ .../provider/live_tracking_provider.g.dart | 175 ++++++++++++++- .../presentation/view/live_tracking_page.dart | 206 ++++++++++++++++++ .../view/live_tracking_session_page.dart | 109 --------- .../live_tracking_page_test.dart | 102 +++++++++ 8 files changed, 619 insertions(+), 111 deletions(-) create mode 100644 lib/modules/location_tracking/presentation/view/live_tracking_page.dart delete mode 100644 lib/modules/location_tracking/presentation/view/live_tracking_session_page.dart create mode 100644 test/modules/location_tracking/live_tracking_page_test.dart diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 87f5ea10..87980e78 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -481,11 +481,31 @@ class MessageLookup extends MessageLookupByLibrary { "liveTrackingActive": MessageLookupByLibrary.simpleMessage( "Live location tracking", ), + "liveTrackingEndReason": MessageLookupByLibrary.simpleMessage("End reason"), + "liveTrackingEndReasonInterrupted": MessageLookupByLibrary.simpleMessage( + "Interrupted", + ), + "liveTrackingEndReasonManual": MessageLookupByLibrary.simpleMessage( + "Stopped manually", + ), + "liveTrackingEndReasonMaxDuration": MessageLookupByLibrary.simpleMessage( + "Reached max duration", + ), + "liveTrackingEnded": MessageLookupByLibrary.simpleMessage("Ended"), "liveTrackingErrors": MessageLookupByLibrary.simpleMessage("Errors"), "liveTrackingFixes": MessageLookupByLibrary.simpleMessage("Fixes"), "liveTrackingHide": MessageLookupByLibrary.simpleMessage("Hide"), "liveTrackingLastError": MessageLookupByLibrary.simpleMessage("Last error"), "liveTrackingLastFix": MessageLookupByLibrary.simpleMessage("Last fix"), + "liveTrackingLastSession": MessageLookupByLibrary.simpleMessage( + "Last session", + ), + "liveTrackingMenuTitle": MessageLookupByLibrary.simpleMessage( + "Live location tracking", + ), + "liveTrackingNoRecord": MessageLookupByLibrary.simpleMessage( + "No active tracking and no recent session.", + ), "liveTrackingNoSession": MessageLookupByLibrary.simpleMessage( "No active tracking session", ), @@ -498,6 +518,9 @@ class MessageLookup extends MessageLookupByLibrary { "liveTrackingSessionTitle": MessageLookupByLibrary.simpleMessage( "Live location tracking", ), + "liveTrackingStartAgain": MessageLookupByLibrary.simpleMessage( + "Start again", + ), "liveTrackingStarted": MessageLookupByLibrary.simpleMessage("Started"), "liveTrackingStatus": MessageLookupByLibrary.simpleMessage("Status"), "liveTrackingStop": MessageLookupByLibrary.simpleMessage("Stop"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 0bf750d1..c3d08bd0 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -3448,6 +3448,91 @@ class S { args: [], ); } + + /// `Live location tracking` + String get liveTrackingMenuTitle { + return Intl.message( + 'Live location tracking', + name: 'liveTrackingMenuTitle', + desc: '', + args: [], + ); + } + + /// `No active tracking and no recent session.` + String get liveTrackingNoRecord { + return Intl.message( + 'No active tracking and no recent session.', + name: 'liveTrackingNoRecord', + desc: '', + args: [], + ); + } + + /// `Last session` + String get liveTrackingLastSession { + return Intl.message( + 'Last session', + name: 'liveTrackingLastSession', + desc: '', + args: [], + ); + } + + /// `Start again` + String get liveTrackingStartAgain { + return Intl.message( + 'Start again', + name: 'liveTrackingStartAgain', + desc: '', + args: [], + ); + } + + /// `Ended` + String get liveTrackingEnded { + return Intl.message('Ended', name: 'liveTrackingEnded', desc: '', args: []); + } + + /// `End reason` + String get liveTrackingEndReason { + return Intl.message( + 'End reason', + name: 'liveTrackingEndReason', + desc: '', + args: [], + ); + } + + /// `Stopped manually` + String get liveTrackingEndReasonManual { + return Intl.message( + 'Stopped manually', + name: 'liveTrackingEndReasonManual', + desc: '', + args: [], + ); + } + + /// `Reached max duration` + String get liveTrackingEndReasonMaxDuration { + return Intl.message( + 'Reached max duration', + name: 'liveTrackingEndReasonMaxDuration', + desc: '', + args: [], + ); + } + + /// `Interrupted` + String get liveTrackingEndReasonInterrupted { + return Intl.message( + 'Interrupted', + name: 'liveTrackingEndReasonInterrupted', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 8169bae3..e1b6249c 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -486,5 +486,14 @@ "liveTrackingStatus": "Status", "liveTrackingStarted": "Started", "liveTrackingLastFix": "Last fix", - "liveTrackingLastError": "Last error" + "liveTrackingLastError": "Last error", + "liveTrackingMenuTitle": "Live location tracking", + "liveTrackingNoRecord": "No active tracking and no recent session.", + "liveTrackingLastSession": "Last session", + "liveTrackingStartAgain": "Start again", + "liveTrackingEnded": "Ended", + "liveTrackingEndReason": "End reason", + "liveTrackingEndReasonManual": "Stopped manually", + "liveTrackingEndReasonMaxDuration": "Reached max duration", + "liveTrackingEndReasonInterrupted": "Interrupted" } \ No newline at end of file diff --git a/lib/modules/location_tracking/presentation/provider/live_tracking_provider.dart b/lib/modules/location_tracking/presentation/provider/live_tracking_provider.dart index a57442e4..b1121b0f 100644 --- a/lib/modules/location_tracking/presentation/provider/live_tracking_provider.dart +++ b/lib/modules/location_tracking/presentation/provider/live_tracking_provider.dart @@ -1,9 +1,14 @@ import 'dart:async'; import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; part 'live_tracking_provider.freezed.dart'; @@ -43,4 +48,18 @@ class LiveTracking extends _$LiveTracking { Future pause() => getIt().pause(); Future resume() => getIt().resume(); + + Future startConfig(LiveTrackingConfig config) => + getIt().start(config); } + +@riverpod +Future targetName( + Ref ref, { + required String entityType, + required String id, +}) => getIt().resolveName(entityType, id); + +@riverpod +Future lastRecord(Ref ref) => + getIt().read(); diff --git a/lib/modules/location_tracking/presentation/provider/live_tracking_provider.g.dart b/lib/modules/location_tracking/presentation/provider/live_tracking_provider.g.dart index 56956bd7..d4534c88 100644 --- a/lib/modules/location_tracking/presentation/provider/live_tracking_provider.g.dart +++ b/lib/modules/location_tracking/presentation/provider/live_tracking_provider.g.dart @@ -6,7 +6,180 @@ part of 'live_tracking_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$liveTrackingHash() => r'34d06583c6c1f73ceb02ee1733d356320f1ad658'; +String _$targetNameHash() => r'645fa4232e9bba656f144a65bb97fc41b9e49ce6'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [targetName]. +@ProviderFor(targetName) +const targetNameProvider = TargetNameFamily(); + +/// See also [targetName]. +class TargetNameFamily extends Family> { + /// See also [targetName]. + const TargetNameFamily(); + + /// See also [targetName]. + TargetNameProvider call({required String entityType, required String id}) { + return TargetNameProvider(entityType: entityType, id: id); + } + + @override + TargetNameProvider getProviderOverride( + covariant TargetNameProvider provider, + ) { + return call(entityType: provider.entityType, id: provider.id); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'targetNameProvider'; +} + +/// See also [targetName]. +class TargetNameProvider extends AutoDisposeFutureProvider { + /// See also [targetName]. + TargetNameProvider({required String entityType, required String id}) + : this._internal( + (ref) => + targetName(ref as TargetNameRef, entityType: entityType, id: id), + from: targetNameProvider, + name: r'targetNameProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$targetNameHash, + dependencies: TargetNameFamily._dependencies, + allTransitiveDependencies: TargetNameFamily._allTransitiveDependencies, + entityType: entityType, + id: id, + ); + + TargetNameProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.entityType, + required this.id, + }) : super.internal(); + + final String entityType; + final String id; + + @override + Override overrideWith( + FutureOr Function(TargetNameRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: TargetNameProvider._internal( + (ref) => create(ref as TargetNameRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + entityType: entityType, + id: id, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _TargetNameProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is TargetNameProvider && + other.entityType == entityType && + other.id == id; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, entityType.hashCode); + hash = _SystemHash.combine(hash, id.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin TargetNameRef on AutoDisposeFutureProviderRef { + /// The parameter `entityType` of this provider. + String get entityType; + + /// The parameter `id` of this provider. + String get id; +} + +class _TargetNameProviderElement + extends AutoDisposeFutureProviderElement + with TargetNameRef { + _TargetNameProviderElement(super.provider); + + @override + String get entityType => (origin as TargetNameProvider).entityType; + @override + String get id => (origin as TargetNameProvider).id; +} + +String _$lastRecordHash() => r'648394a8af4909026aa63b36d9e48965560bf5da'; + +/// See also [lastRecord]. +@ProviderFor(lastRecord) +final lastRecordProvider = + AutoDisposeFutureProvider.internal( + lastRecord, + name: r'lastRecordProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$lastRecordHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef LastRecordRef = AutoDisposeFutureProviderRef; +String _$liveTrackingHash() => r'bd053f24dfe54ab8514d60e1cda51896cea7d15b'; /// See also [LiveTracking]. @ProviderFor(LiveTracking) diff --git a/lib/modules/location_tracking/presentation/view/live_tracking_page.dart b/lib/modules/location_tracking/presentation/view/live_tracking_page.dart new file mode 100644 index 00000000..6a49be59 --- /dev/null +++ b/lib/modules/location_tracking/presentation/view/live_tracking_page.dart @@ -0,0 +1,206 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:thingsboard_app/generated/l10n.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/modules/location_tracking/presentation/provider/live_tracking_provider.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_display.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; +import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; + +class LiveTrackingPage extends ConsumerWidget { + const LiveTrackingPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final session = ref.watch(liveTrackingProvider).session; + return Scaffold( + appBar: AppBar(title: Text(S.of(context).liveTrackingSessionTitle)), + body: session != null ? _ActiveSession(session: session) : _IdleView(), + ); + } +} + +class _ActiveSession extends ConsumerWidget { + const _ActiveSession({required this.session}); + + final LiveTrackingSession session; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tracking = session.status == LiveTrackingStatus.tracking; + final lastFix = session.lastFix; + final target = session.config.target; + final nameAsync = ref.watch( + targetNameProvider(entityType: target.entityType, id: target.id), + ); + final name = displayTargetName(nameAsync.valueOrNull, target); + return ListView( + children: [ + ListTile( + title: Text(S.of(context).liveTrackingTarget), + subtitle: Text(name), + ), + ListTile( + title: Text(S.of(context).liveTrackingStatus), + subtitle: Text( + tracking + ? S.of(context).liveTrackingActive + : S.of(context).liveTrackingPaused, + ), + ), + ListTile( + title: Text(S.of(context).liveTrackingStarted), + subtitle: Text(session.startedAt.toLocal().toString()), + ), + ListTile( + title: Text( + '${S.of(context).liveTrackingFixes}: ${session.fixCount} · ' + '${S.of(context).liveTrackingSaved}: ${session.savedCount} · ' + '${S.of(context).liveTrackingErrors}: ${session.saveErrorCount}', + ), + ), + if (lastFix != null) + ListTile( + title: Text(S.of(context).liveTrackingLastFix), + subtitle: Text( + '${lastFix.latitude.toStringAsFixed(6)}, ' + '${lastFix.longitude.toStringAsFixed(6)} ' + '(±${lastFix.accuracy.toStringAsFixed(0)} m)', + ), + ), + if (session.lastError != null) + ListTile( + title: Text(S.of(context).liveTrackingLastError), + subtitle: Text( + session.lastError!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () { + final notifier = ref.read(liveTrackingProvider.notifier); + tracking ? notifier.pause() : notifier.resume(); + }, + icon: Icon(tracking ? Icons.pause : Icons.play_arrow), + label: Text( + tracking + ? S.of(context).liveTrackingPause + : S.of(context).liveTrackingResume, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.icon( + onPressed: + () => ref.read(liveTrackingProvider.notifier).stop(), + icon: const Icon(Icons.stop), + label: Text(S.of(context).liveTrackingStop), + ), + ), + ], + ), + ), + ], + ); + } +} + +class _IdleView extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final recordAsync = ref.watch(lastRecordProvider); + return recordAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, _) => Center(child: Text(S.of(context).liveTrackingNoRecord)), + data: (record) { + if (record == null) { + return Center(child: Text(S.of(context).liveTrackingNoRecord)); + } + return _LastSession(record: record); + }, + ); + } +} + +class _LastSession extends ConsumerWidget { + const _LastSession({required this.record}); + + final LastTrackingRecord record; + + String _endReasonLabel(BuildContext context) => switch (record.endReason) { + TrackingEndReason.manual => S.of(context).liveTrackingEndReasonManual, + TrackingEndReason.maxDuration => + S.of(context).liveTrackingEndReasonMaxDuration, + TrackingEndReason.interrupted => + S.of(context).liveTrackingEndReasonInterrupted, + }; + + Future _startAgain(WidgetRef ref) async { + // AuthUser.sub is the current user's email (phase-1c trackedBy semantics); + // re-derive it so a relaunch is attributed to whoever is logged in now. + final email = getIt().client.getAuthUser()?.sub; + final config = LiveTrackingConfig.fromJson({ + ...record.configJson, + if (email != null) 'trackedBy': email, + }); + await ref.read(liveTrackingProvider.notifier).startConfig(config); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final target = record.config.target; + final name = displayTargetName(record.targetName, target); + return ListView( + children: [ + ListTile( + title: Text(S.of(context).liveTrackingLastSession), + subtitle: Text(name), + ), + ListTile( + title: Text(S.of(context).liveTrackingStarted), + subtitle: Text(record.startedAt.toLocal().toString()), + ), + if (record.endedAt != null) + ListTile( + title: Text(S.of(context).liveTrackingEnded), + subtitle: Text(record.endedAt!.toLocal().toString()), + ), + ListTile( + title: Text(S.of(context).liveTrackingEndReason), + subtitle: Text(_endReasonLabel(context)), + ), + ListTile( + title: Text( + '${S.of(context).liveTrackingFixes}: ${record.fixCount} · ' + '${S.of(context).liveTrackingSaved}: ${record.savedCount} · ' + '${S.of(context).liveTrackingErrors}: ${record.saveErrorCount}', + ), + ), + if (record.lastLat != null && record.lastLng != null) + ListTile( + title: Text(S.of(context).liveTrackingLastFix), + subtitle: Text( + '${record.lastLat!.toStringAsFixed(6)}, ' + '${record.lastLng!.toStringAsFixed(6)}', + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: FilledButton.icon( + onPressed: () => _startAgain(ref), + icon: const Icon(Icons.play_arrow), + label: Text(S.of(context).liveTrackingStartAgain), + ), + ), + ], + ); + } +} diff --git a/lib/modules/location_tracking/presentation/view/live_tracking_session_page.dart b/lib/modules/location_tracking/presentation/view/live_tracking_session_page.dart deleted file mode 100644 index e327dfdd..00000000 --- a/lib/modules/location_tracking/presentation/view/live_tracking_session_page.dart +++ /dev/null @@ -1,109 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:thingsboard_app/generated/l10n.dart'; -import 'package:thingsboard_app/modules/location_tracking/presentation/provider/live_tracking_provider.dart'; -import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; - -class LiveTrackingSessionPage extends ConsumerWidget { - const LiveTrackingSessionPage({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final session = ref.watch(liveTrackingProvider).session; - return Scaffold( - appBar: AppBar(title: Text(S.of(context).liveTrackingSessionTitle)), - body: - session == null - ? Center(child: Text(S.of(context).liveTrackingNoSession)) - : _SessionDetails(session: session), - ); - } -} - -class _SessionDetails extends ConsumerWidget { - const _SessionDetails({required this.session}); - - final LiveTrackingSession session; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final tracking = session.status == LiveTrackingStatus.tracking; - final lastFix = session.lastFix; - return ListView( - children: [ - ListTile( - title: Text(S.of(context).liveTrackingTarget), - subtitle: Text( - '${session.config.target.entityType} ${session.config.target.id}', - ), - ), - ListTile( - title: Text(S.of(context).liveTrackingStatus), - subtitle: Text( - tracking - ? S.of(context).liveTrackingActive - : S.of(context).liveTrackingPaused, - ), - ), - ListTile( - title: Text(S.of(context).liveTrackingStarted), - subtitle: Text(session.startedAt.toLocal().toString()), - ), - ListTile( - title: Text( - '${S.of(context).liveTrackingFixes}: ${session.fixCount} · ' - '${S.of(context).liveTrackingSaved}: ${session.savedCount} · ' - '${S.of(context).liveTrackingErrors}: ${session.saveErrorCount}', - ), - ), - if (lastFix != null) - ListTile( - title: Text(S.of(context).liveTrackingLastFix), - subtitle: Text( - '${lastFix.latitude.toStringAsFixed(6)}, ' - '${lastFix.longitude.toStringAsFixed(6)} ' - '(±${lastFix.accuracy.toStringAsFixed(0)} m)', - ), - ), - if (session.lastError != null) - ListTile( - title: Text(S.of(context).liveTrackingLastError), - subtitle: Text( - session.lastError!, - style: TextStyle(color: Theme.of(context).colorScheme.error), - ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Expanded( - child: OutlinedButton.icon( - onPressed: () { - final notifier = ref.read(liveTrackingProvider.notifier); - tracking ? notifier.pause() : notifier.resume(); - }, - icon: Icon(tracking ? Icons.pause : Icons.play_arrow), - label: Text( - tracking - ? S.of(context).liveTrackingPause - : S.of(context).liveTrackingResume, - ), - ), - ), - const SizedBox(width: 12), - Expanded( - child: FilledButton.icon( - onPressed: - () => ref.read(liveTrackingProvider.notifier).stop(), - icon: const Icon(Icons.stop), - label: Text(S.of(context).liveTrackingStop), - ), - ), - ], - ), - ), - ], - ); - } -} diff --git a/test/modules/location_tracking/live_tracking_page_test.dart b/test/modules/location_tracking/live_tracking_page_test.dart new file mode 100644 index 00000000..2ddc738e --- /dev/null +++ b/test/modules/location_tracking/live_tracking_page_test.dart @@ -0,0 +1,102 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:thingsboard_app/generated/l10n.dart'; +import 'package:thingsboard_app/modules/location_tracking/presentation/view/live_tracking_page.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; + +class _FakeStore implements ILiveTrackingStore { + _FakeStore(this.record); + final LastTrackingRecord? record; + @override + Future read() async => record; + @override + Future write(LastTrackingRecord r) async {} + @override + Future clear() async {} +} + +class _FakeResolver implements IEntityNameResolver { + @override + Future resolveName(String entityType, String id) async => null; +} + +class _FakeTrackingService implements ILiveLocationTrackingService { + final _controller = StreamController.broadcast(); + + @override + LiveTrackingSession? session; + + @override + Stream get sessionStream => _controller.stream; + + @override + Future start(LiveTrackingConfig config) async {} + + @override + Future stop() async {} + + @override + Future pause() async {} + + @override + Future resume() async {} +} + +Widget _wrap() => const ProviderScope( + child: MaterialApp( + localizationsDelegates: [S.delegate], + home: LiveTrackingPage(), + ), +); + +void main() { + tearDown(() => GetIt.I.reset()); + + testWidgets('idle with no record shows the empty message', (tester) async { + GetIt.I.registerLazySingleton(() => _FakeStore(null)); + GetIt.I.registerLazySingleton(() => _FakeResolver()); + GetIt.I.registerLazySingleton( + () => _FakeTrackingService(), + ); + + await tester.pumpWidget(_wrap()); + await tester.pumpAndSettle(); + + expect( + find.text('No active tracking and no recent session.'), + findsOneWidget, + ); + }); + + testWidgets('idle with a record shows Start again', (tester) async { + final record = LastTrackingRecord( + configJson: const { + 'target': {'entityType': 'DEVICE', 'id': 'd-1'}, + }, + startedAt: DateTime.fromMillisecondsSinceEpoch(0), + endedAt: DateTime.fromMillisecondsSinceEpoch(60000), + endReason: TrackingEndReason.manual, + targetName: 'My Tracker', + ); + GetIt.I.registerLazySingleton(() => _FakeStore(record)); + GetIt.I.registerLazySingleton(() => _FakeResolver()); + GetIt.I.registerLazySingleton( + () => _FakeTrackingService(), + ); + + await tester.pumpWidget(_wrap()); + await tester.pumpAndSettle(); + + expect(find.text('Start again'), findsOneWidget); + expect(find.text('My Tracker'), findsWidgets); + }); +} From ff3b6ca5f90de3c116d027dbf71b2674349c26e0 Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 22 Jul 2026 18:53:59 +0300 Subject: [PATCH 24/40] feat(location): map live tracking bundle page and remove debug spike --- .../routes/location_tracking_routes.dart | 16 +- .../view/live_tracking_spike_page.dart | 283 ------------------ .../widgets/live_tracking_bar.dart | 2 +- .../main/providers/navigation_helper.dart | 205 +++++++------ lib/modules/more/more_page.dart | 14 - lib/utils/services/layouts/pages_layout.dart | 1 + .../navigation_helper_live_tracking_test.dart | 21 ++ 7 files changed, 134 insertions(+), 408 deletions(-) delete mode 100644 lib/modules/location_tracking/presentation/view/live_tracking_spike_page.dart create mode 100644 test/modules/location_tracking/navigation_helper_live_tracking_test.dart diff --git a/lib/config/routes/v2/routes_config/routes/location_tracking_routes.dart b/lib/config/routes/v2/routes_config/routes/location_tracking_routes.dart index cbaa7788..28b686be 100644 --- a/lib/config/routes/v2/routes_config/routes/location_tracking_routes.dart +++ b/lib/config/routes/v2/routes_config/routes/location_tracking_routes.dart @@ -1,23 +1,15 @@ import 'package:go_router/go_router.dart'; -import 'package:thingsboard_app/modules/location_tracking/presentation/view/live_tracking_session_page.dart'; -import 'package:thingsboard_app/modules/location_tracking/presentation/view/live_tracking_spike_page.dart'; +import 'package:thingsboard_app/modules/location_tracking/presentation/view/live_tracking_page.dart'; class LocationTrackingRoutes { - static const liveTrackingSpike = '/liveTrackingSpike'; - static const liveTrackingSession = '/liveTrackingSession'; + static const liveTracking = '/liveTracking'; } final List locationTrackingRoutes = [ GoRoute( - path: LocationTrackingRoutes.liveTrackingSpike, + path: LocationTrackingRoutes.liveTracking, builder: (context, state) { - return const LiveTrackingSpikePage(); - }, - ), - GoRoute( - path: LocationTrackingRoutes.liveTrackingSession, - builder: (context, state) { - return const LiveTrackingSessionPage(); + return const LiveTrackingPage(); }, ), ]; diff --git a/lib/modules/location_tracking/presentation/view/live_tracking_spike_page.dart b/lib/modules/location_tracking/presentation/view/live_tracking_spike_page.dart deleted file mode 100644 index a31142d6..00000000 --- a/lib/modules/location_tracking/presentation/view/live_tracking_spike_page.dart +++ /dev/null @@ -1,283 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:flutter/material.dart'; -import 'package:thingsboard_app/locator.dart'; -import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; -import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; -import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; -import 'package:thingsboard_app/utils/services/location/model/location_stream_settings.dart'; -import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; - -/// Phase 1a spike (see docs/superpowers/specs/2026-07-03-gps-tracking-design.md): -/// validates background/foreground live tracking and REST telemetry saves on -/// real devices before the production tracking service is built. Debug-only -/// entry point on the More page; throwaway UI by design. -class LiveTrackingSpikePage extends StatefulWidget { - const LiveTrackingSpikePage({super.key}); - - @override - State createState() => _LiveTrackingSpikePageState(); -} - -class _SpikeEvent { - _SpikeEvent(this.message, {this.isError = false}) : time = DateTime.now(); - - final DateTime time; - final String message; - final bool isError; -} - -class _LiveTrackingSpikePageState extends State { - static const _maxEvents = 200; - - bool _backgroundMode = true; - bool _saveTelemetry = true; - - StreamSubscription? _subscription; - Timer? _ticker; - DateTime? _startedAt; - int _fixCount = 0; - int _savedCount = 0; - int _saveErrorCount = 0; - GeoPosition? _lastFix; - final List<_SpikeEvent> _events = []; - - bool get _isRunning => _subscription != null; - - @override - void dispose() { - _ticker?.cancel(); - _subscription?.cancel(); - super.dispose(); - } - - void _start() { - final settings = LocationStreamSettings( - background: - _backgroundMode - ? const BackgroundTrackingConfig( - notificationTitle: 'ThingsBoard live tracking', - notificationText: 'Sharing phone location (spike)', - ) - : null, - ); - - _startedAt = DateTime.now(); - _fixCount = 0; - _savedCount = 0; - _saveErrorCount = 0; - _lastFix = null; - _events.clear(); - _logEvent( - 'Started (${_backgroundMode ? 'background' : 'foreground'} mode, ' - 'telemetry ${_saveTelemetry ? 'on' : 'off'})', - ); - - _subscription = getIt() - .positionStream(settings: settings) - .listen(_onFix, onDone: _stop); - _ticker = Timer.periodic(const Duration(seconds: 1), (_) { - if (mounted) setState(() {}); - }); - setState(() {}); - } - - void _stop() { - _ticker?.cancel(); - _ticker = null; - _subscription?.cancel(); - _subscription = null; - _logEvent('Stopped'); - if (mounted) setState(() {}); - } - - void _onFix(LocationFix fix) { - switch (fix) { - case LocationSuccess(:final position): - _fixCount++; - _lastFix = position; - _logEvent( - '${position.latitude.toStringAsFixed(6)}, ' - '${position.longitude.toStringAsFixed(6)} ' - '(±${position.accuracy.toStringAsFixed(0)} m)', - ); - if (_saveTelemetry) { - unawaited(_saveFix(position)); - } - case LocationServicesDisabled(): - _logEvent('Location services disabled', isError: true); - _stop(); - case LocationPermissionDenied(): - _logEvent('Location permission denied', isError: true); - _stop(); - case LocationPermissionDeniedForever(): - _logEvent('Location permission permanently denied', isError: true); - _stop(); - case LocationFixError(:final message): - _logEvent('Fix error: $message', isError: true); - } - if (mounted) setState(() {}); - } - - Future _saveFix(GeoPosition position) async { - try { - final client = getIt().client; - final userId = client.getAuthUser()?.userId; - if (userId == null) { - throw Exception('No authenticated user'); - } - - await client.getTelemetryControllerApi().saveEntityTelemetry( - entityType: 'USER', - entityId: userId, - scope: 'ANY', - body: jsonEncode({ - 'ts': (position.timestamp ?? DateTime.now()).millisecondsSinceEpoch, - 'values': { - 'latitude': position.latitude, - 'longitude': position.longitude, - 'gpsAccuracy': position.accuracy, - }, - }), - ); - _savedCount++; - } catch (e) { - _saveErrorCount++; - _logEvent('Telemetry save failed: $e', isError: true); - } - if (mounted) setState(() {}); - } - - void _logEvent(String message, {bool isError = false}) { - _events.insert(0, _SpikeEvent(message, isError: isError)); - if (_events.length > _maxEvents) { - _events.removeLast(); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('GPS tracking spike')), - body: Column( - children: [ - SwitchListTile( - title: const Text('Background tracking'), - subtitle: const Text( - 'Foreground service on Android, background mode on iOS', - ), - value: _backgroundMode, - onChanged: - _isRunning - ? null - : (value) => setState(() => _backgroundMode = value), - ), - SwitchListTile( - title: const Text('Save telemetry to my user entity'), - subtitle: const Text('latitude / longitude / gpsAccuracy'), - value: _saveTelemetry, - onChanged: - _isRunning - ? null - : (value) => setState(() => _saveTelemetry = value), - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - Expanded( - child: FilledButton.icon( - onPressed: _isRunning ? _stop : _start, - icon: Icon(_isRunning ? Icons.stop : Icons.play_arrow), - label: Text(_isRunning ? 'Stop' : 'Start'), - ), - ), - ], - ), - ), - _StatsCard( - isRunning: _isRunning, - startedAt: _startedAt, - fixCount: _fixCount, - savedCount: _savedCount, - saveErrorCount: _saveErrorCount, - lastFix: _lastFix, - ), - const Divider(height: 1), - Expanded( - child: ListView.builder( - itemCount: _events.length, - itemBuilder: (context, index) { - final event = _events[index]; - return ListTile( - dense: true, - leading: Text( - TimeOfDay.fromDateTime(event.time).format(context), - ), - title: Text( - event.message, - style: - event.isError - ? TextStyle( - color: Theme.of(context).colorScheme.error, - ) - : null, - ), - ); - }, - ), - ), - ], - ), - ); - } -} - -class _StatsCard extends StatelessWidget { - const _StatsCard({ - required this.isRunning, - required this.startedAt, - required this.fixCount, - required this.savedCount, - required this.saveErrorCount, - required this.lastFix, - }); - - final bool isRunning; - final DateTime? startedAt; - final int fixCount; - final int savedCount; - final int saveErrorCount; - final GeoPosition? lastFix; - - @override - Widget build(BuildContext context) { - final elapsed = - startedAt == null ? null : DateTime.now().difference(startedAt!); - final lastFixTime = lastFix?.timestamp; - final lastFixAge = - lastFixTime == null ? null : DateTime.now().difference(lastFixTime); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - [ - if (isRunning) 'RUNNING' else 'STOPPED', - if (elapsed != null) 'elapsed: ${_format(elapsed)}', - 'fixes: $fixCount', - 'saved: $savedCount', - if (saveErrorCount > 0) 'save errors: $saveErrorCount', - if (lastFixAge != null) 'last fix: ${_format(lastFixAge)} ago', - ].join(' · '), - style: Theme.of(context).textTheme.bodyMedium, - ), - ); - } - - String _format(Duration d) { - final minutes = d.inMinutes; - final seconds = d.inSeconds % 60; - return minutes > 0 ? '${minutes}m ${seconds}s' : '${seconds}s'; - } -} diff --git a/lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart b/lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart index af89f63a..4a6388f7 100644 --- a/lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart +++ b/lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart @@ -40,7 +40,7 @@ class LiveTrackingBar extends ConsumerWidget { return Material( color: colors.primaryContainer, child: InkWell( - onTap: () => context.push(LocationTrackingRoutes.liveTrackingSession), + onTap: () => context.push(LocationTrackingRoutes.liveTracking), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: Row( diff --git a/lib/modules/main/providers/navigation_helper.dart b/lib/modules/main/providers/navigation_helper.dart index 2514ad45..50df9dfc 100644 --- a/lib/modules/main/providers/navigation_helper.dart +++ b/lib/modules/main/providers/navigation_helper.dart @@ -33,14 +33,18 @@ class NavigationHelper { return s.deviceList; case 'dashboards': return s.dashboards(2); + case 'live_location_tracking': + return s.liveTrackingMenuTitle; } if (path == '/more') return s.more; if (path.contains('/profile')) return s.profile; return fallbackTitle; } -static int? getCurrentIndexFromPath(String path, List items) { - + static int? getCurrentIndexFromPath( + String path, + List items, + ) { for (int i = 0; i < items.length; i++) { if (items[i].path == path) { return i; @@ -49,111 +53,116 @@ static int? getCurrentIndexFromPath(String path, List items return null; } -// Helper functions -static String getLabel(PageLayout pageLayout) { - if (pageLayout.label != null) { - return pageLayout.label!; - } - - switch (pageLayout.id) { - case Pages.home: - return 'Home'; - case Pages.alarms: - return 'Alarms'; - case Pages.devices: - return 'Devices'; - case Pages.customers: - return 'Customers'; - case Pages.assets: - return 'Assets'; - case Pages.audit_logs: - return 'Audit Logs'; - case Pages.notifications: - return 'Notifications'; - case Pages.device_list: - return 'Device List'; - case Pages.dashboards: - return 'Dashboards'; - case Pages.undefined: - case null: - return pageLayout.label ?? '-'; - } -} + // Helper functions + static String getLabel(PageLayout pageLayout) { + if (pageLayout.label != null) { + return pageLayout.label!; + } -static IconData getIcon(PageLayout pageLayout) { - if (pageLayout.icon != null) { - return getIconFromString(pageLayout.icon); + switch (pageLayout.id) { + case Pages.home: + return 'Home'; + case Pages.alarms: + return 'Alarms'; + case Pages.devices: + return 'Devices'; + case Pages.customers: + return 'Customers'; + case Pages.assets: + return 'Assets'; + case Pages.audit_logs: + return 'Audit Logs'; + case Pages.notifications: + return 'Notifications'; + case Pages.device_list: + return 'Device List'; + case Pages.dashboards: + return 'Dashboards'; + case Pages.live_location_tracking: + return 'Live location tracking'; + case Pages.undefined: + case null: + return pageLayout.label ?? '-'; + } } - switch (pageLayout.id) { - case Pages.home: - return Icons.home_outlined; - case Pages.alarms: - return Icons.notifications_outlined; - case Pages.devices: - return Icons.devices_other_outlined; - case Pages.customers: - return Icons.supervisor_account_sharp; - case Pages.assets: - return Icons.domain_outlined; - case Pages.audit_logs: - return Icons.track_changes_outlined; - case Pages.notifications: - return Icons.notifications_active_sharp; - case Pages.device_list: - return Icons.devices; - case Pages.dashboards: - return Icons.dashboard_outlined; - case Pages.undefined: - case null: + static IconData getIcon(PageLayout pageLayout) { + if (pageLayout.icon != null) { return getIconFromString(pageLayout.icon); + } + + switch (pageLayout.id) { + case Pages.home: + return Icons.home_outlined; + case Pages.alarms: + return Icons.notifications_outlined; + case Pages.devices: + return Icons.devices_other_outlined; + case Pages.customers: + return Icons.supervisor_account_sharp; + case Pages.assets: + return Icons.domain_outlined; + case Pages.audit_logs: + return Icons.track_changes_outlined; + case Pages.notifications: + return Icons.notifications_active_sharp; + case Pages.device_list: + return Icons.devices; + case Pages.dashboards: + return Icons.dashboard_outlined; + case Pages.live_location_tracking: + return Icons.my_location; + case Pages.undefined: + case null: + return getIconFromString(pageLayout.icon); + } } -} -static String getPath(PageLayout pageLayout) { - switch (pageLayout.id) { - case Pages.home: - return '/home'; - case Pages.alarms: - return '/alarms'; - case Pages.devices: - return '/devices'; - case Pages.customers: - return '/customers'; - case Pages.assets: - return '/assets'; - case Pages.audit_logs: - return '/auditLogs'; - case Pages.notifications: - return '/notifications'; - case Pages.device_list: - return '/deviceList'; - case Pages.dashboards: - return '/dashboards'; - case Pages.undefined: - case null: - if (pageLayout.url != null) { - return '/url/${Uri.encodeComponent(pageLayout.url!)}'; - } else if (pageLayout.dashboardId != null) { - return '/dashboard/${pageLayout.dashboardId}'; - } else if (pageLayout.path?.startsWith('/url/') == true) { - return '/url/${Uri.encodeComponent(pageLayout.path!.split('/url/').last)}'; - } + static String getPath(PageLayout pageLayout) { + switch (pageLayout.id) { + case Pages.home: + return '/home'; + case Pages.alarms: + return '/alarms'; + case Pages.devices: + return '/devices'; + case Pages.customers: + return '/customers'; + case Pages.assets: + return '/assets'; + case Pages.audit_logs: + return '/auditLogs'; + case Pages.notifications: + return '/notifications'; + case Pages.device_list: + return '/deviceList'; + case Pages.dashboards: + return '/dashboards'; + case Pages.live_location_tracking: + return '/liveTracking'; + case Pages.undefined: + case null: + if (pageLayout.url != null) { + return '/url/${Uri.encodeComponent(pageLayout.url!)}'; + } else if (pageLayout.dashboardId != null) { + return '/dashboard/${pageLayout.dashboardId}'; + } else if (pageLayout.path?.startsWith('/url/') == true) { + return '/url/${Uri.encodeComponent(pageLayout.path!.split('/url/').last)}'; + } - return pageLayout.path ?? '/error'; + return pageLayout.path ?? '/error'; + } } -} -static IconData getIconFromString(String? icon) { - if (icon != null) { - if (icon.contains('mdi')) { - return MdiIcons.fromString(icon.split('mdi:').last) ?? - Icons.error_outline; - } + static IconData getIconFromString(String? icon) { + if (icon != null) { + if (icon.contains('mdi')) { + return MdiIcons.fromString(icon.split('mdi:').last) ?? + Icons.error_outline; + } - return materialIconsMap[icon] ?? Icons.error_outline; + return materialIconsMap[icon] ?? Icons.error_outline; + } + return Icons.error_outline; } - return Icons.error_outline; -} - } diff --git a/lib/modules/more/more_page.dart b/lib/modules/more/more_page.dart index d1fc488b..22324053 100644 --- a/lib/modules/more/more_page.dart +++ b/lib/modules/more/more_page.dart @@ -1,10 +1,8 @@ -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:thingsboard_app/config/routes/v2/routes_config/routes/location_tracking_routes.dart'; import 'package:thingsboard_app/config/themes/app_colors.dart'; import 'package:thingsboard_app/core/auth/login/provider/login_provider.dart'; import 'package:thingsboard_app/core/usecases/user_details_usecase.dart'; @@ -82,18 +80,6 @@ class MorePage extends HookConsumerWidget { ), ), ], - // Debug-only entry to the phase 1a GPS tracking spike page. - if (kDebugMode) - MoreMenuItemWidget( - const TbMainNavigationItem( - title: 'GPS tracking spike', - icon: Icons.gps_fixed, - path: LocationTrackingRoutes.liveTrackingSpike, - ), - onTap: () { - context.push(LocationTrackingRoutes.liveTrackingSpike); - }, - ), Divider( color: Colors.black.withValues(alpha: .05), thickness: 1, diff --git a/lib/utils/services/layouts/pages_layout.dart b/lib/utils/services/layouts/pages_layout.dart index e2395f64..665a2ce5 100644 --- a/lib/utils/services/layouts/pages_layout.dart +++ b/lib/utils/services/layouts/pages_layout.dart @@ -8,6 +8,7 @@ enum Pages { notifications, device_list, dashboards, + live_location_tracking, undefined, } diff --git a/test/modules/location_tracking/navigation_helper_live_tracking_test.dart b/test/modules/location_tracking/navigation_helper_live_tracking_test.dart new file mode 100644 index 00000000..aba91f2c --- /dev/null +++ b/test/modules/location_tracking/navigation_helper_live_tracking_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/modules/main/providers/navigation_helper.dart'; +import 'package:thingsboard_app/utils/services/layouts/pages_layout.dart'; + +void main() { + const layout = PageLayout(id: Pages.live_location_tracking); + + test('parses LIVE_LOCATION_TRACKING from server string', () { + expect( + pagesFromString('LIVE_LOCATION_TRACKING'), + Pages.live_location_tracking, + ); + }); + + test('maps to route, label and icon', () { + expect(NavigationHelper.getPath(layout), '/liveTracking'); + expect(NavigationHelper.getLabel(layout), 'Live location tracking'); + expect(NavigationHelper.getIcon(layout), Icons.my_location); + }); +} From 9e32936db10180a2d70f89f5747220ad31cfc244 Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 22 Jul 2026 18:59:03 +0300 Subject: [PATCH 25/40] feat(location): full-width pulsing collapsed tracking bar --- .../widgets/live_tracking_bar.dart | 62 ++++++++++++++----- .../live_tracking_bar_test.dart | 35 +++++++++++ 2 files changed, 83 insertions(+), 14 deletions(-) diff --git a/lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart b/lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart index 4a6388f7..1bf6acac 100644 --- a/lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart +++ b/lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:thingsboard_app/config/routes/v2/routes_config/routes/location_tracking_routes.dart'; @@ -21,20 +22,7 @@ class LiveTrackingBar extends ConsumerWidget { final tracking = session.status == LiveTrackingStatus.tracking; if (viewState.hidden) { - return Material( - color: colors.primaryContainer, - child: InkWell( - onTap: () => ref.read(liveTrackingProvider.notifier).show(), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Icon( - tracking ? Icons.gps_fixed : Icons.gps_off, - size: 16, - color: colors.onPrimaryContainer, - ), - ), - ), - ); + return _CollapsedBar(tracking: tracking); } return Material( @@ -106,3 +94,49 @@ class LiveTrackingBar extends ConsumerWidget { ); } } + +class _CollapsedBar extends HookConsumerWidget { + const _CollapsedBar({required this.tracking}); + + final bool tracking; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = Theme.of(context).colorScheme; + final controller = useAnimationController( + duration: const Duration(milliseconds: 900), + ); + useEffect(() { + if (tracking) { + controller.repeat(reverse: true); + } else { + controller.stop(); + controller.value = 1; + } + return null; + }, [tracking]); + + return Material( + color: colors.primaryContainer, + child: InkWell( + onTap: () => ref.read(liveTrackingProvider.notifier).show(), + child: SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Center( + child: FadeTransition( + opacity: Tween(begin: 0.35, end: 1).animate(controller), + child: Icon( + tracking ? Icons.gps_fixed : Icons.gps_off, + size: 18, + color: colors.onPrimaryContainer, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/test/modules/location_tracking/live_tracking_bar_test.dart b/test/modules/location_tracking/live_tracking_bar_test.dart index 721b7d27..39a2de3a 100644 --- a/test/modules/location_tracking/live_tracking_bar_test.dart +++ b/test/modules/location_tracking/live_tracking_bar_test.dart @@ -80,4 +80,39 @@ void main() { await tester.tap(find.byIcon(Icons.stop)); expect(tracking.stopCalled, true); }); + + testWidgets( + 'collapsed bar spans full width and shows gps_fixed when tracking', + (tester) async { + tracking.session = LiveTrackingSession( + config: const LiveTrackingConfig( + target: LiveTrackingTarget(entityType: 'DEVICE', id: 'd-1'), + ), + status: LiveTrackingStatus.tracking, + startedAt: DateTime.fromMillisecondsSinceEpoch(0), + ); + + await tester.pumpWidget(_wrap(const LiveTrackingBar())); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.expand_less)); + await tester.pump(); + + expect(find.byIcon(Icons.gps_fixed), findsOneWidget); + final collapsedMaterialFinder = + find + .ancestor( + of: find.byIcon(Icons.gps_fixed), + matching: find.byType(Material), + ) + .first; + final material = tester.widget(collapsedMaterialFinder); + expect(material.color, isNotNull); + final size = tester.getSize(collapsedMaterialFinder); + expect( + size.width, + tester.view.physicalSize.width / tester.view.devicePixelRatio, + ); + }, + ); } From d80b3ba00cbb45be48f6eee7df7346a4c255dcba Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 22 Jul 2026 19:03:40 +0300 Subject: [PATCH 26/40] feat(location): stop tracking and clear last-session record on logout --- lib/core/context/tb_context.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/core/context/tb_context.dart b/lib/core/context/tb_context.dart index 93cd0690..8814ffef 100644 --- a/lib/core/context/tb_context.dart +++ b/lib/core/context/tb_context.dart @@ -16,6 +16,8 @@ import 'package:thingsboard_app/utils/services/version_service/version_info.dart import 'package:thingsboard_app/utils/services/device_info/i_device_info_service.dart'; import 'package:thingsboard_app/utils/services/endpoint/i_endpoint_service.dart'; import 'package:thingsboard_app/utils/services/firebase/i_firebase_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; import 'package:thingsboard_app/utils/services/notification_service.dart'; import 'package:thingsboard_app/utils/services/overlay_service/i_overlay_service.dart'; import 'package:thingsboard_app/utils/utils.dart'; @@ -307,6 +309,9 @@ class TbContext implements PopEntry { await getIt().init(); } + await getIt().stop(); + await getIt().clear(); + await tbClient.logout(requestConfig: requestConfig, notifyUser: notifyUser); _appLinkStreamSubscription?.cancel(); From 04ddfe7c1d8334c54ac560fc9783b11d7683152b Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 22 Jul 2026 21:28:34 +0300 Subject: [PATCH 27/40] fix(location): resolve target name off start()'s critical path start() was awaiting the network entity-name lookup before persisting the recoverable "interrupted" record and subscribing to GPS. A force kill (or slow/offline network) in that window left nothing persisted to recover from, and a concurrent stop()/logout could resume start() afterwards and resurrect a subscription and record for a session that had already ended. Reorder start() so the record write, status attributes, GPS subscription and max-duration timer all happen synchronously before any network call, then resolve the name in the background and patch it into the persisted record. The patch is guarded by the session's startedAt so a stop, logout, or newer start() supersedes it and the late name resolution becomes a no-op. Also: - remove the dead liveTrackingNoSession l10n key (its only consumer, the old session page, is gone) and regenerate l10n - tighten two loose test matchers in the location tracking widget tests (findsOneWidget instead of findsWidgets; assert the exact primaryContainer color instead of just isNotNull) --- lib/generated/intl/messages_en.dart | 3 - lib/generated/l10n.dart | 10 ---- lib/l10n/intl_en.arb | 1 - .../live_location_tracking_service.dart | 36 +++++++++-- .../live_tracking_bar_test.dart | 4 +- .../live_tracking_page_test.dart | 2 +- .../live_location_tracking_service_test.dart | 59 ++++++++++++++++++- 7 files changed, 93 insertions(+), 22 deletions(-) diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 87980e78..6083465c 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -506,9 +506,6 @@ class MessageLookup extends MessageLookupByLibrary { "liveTrackingNoRecord": MessageLookupByLibrary.simpleMessage( "No active tracking and no recent session.", ), - "liveTrackingNoSession": MessageLookupByLibrary.simpleMessage( - "No active tracking session", - ), "liveTrackingPause": MessageLookupByLibrary.simpleMessage("Pause"), "liveTrackingPaused": MessageLookupByLibrary.simpleMessage( "Live tracking paused", diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index c3d08bd0..427213eb 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -3389,16 +3389,6 @@ class S { ); } - /// `No active tracking session` - String get liveTrackingNoSession { - return Intl.message( - 'No active tracking session', - name: 'liveTrackingNoSession', - desc: '', - args: [], - ); - } - /// `Target entity` String get liveTrackingTarget { return Intl.message( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index e1b6249c..6779715a 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -481,7 +481,6 @@ "liveTrackingResume": "Resume", "liveTrackingHide": "Hide", "liveTrackingSessionTitle": "Live location tracking", - "liveTrackingNoSession": "No active tracking session", "liveTrackingTarget": "Target entity", "liveTrackingStatus": "Status", "liveTrackingStarted": "Started", diff --git a/lib/utils/services/live_location_tracking/live_location_tracking_service.dart b/lib/utils/services/live_location_tracking/live_location_tracking_service.dart index f910340b..cb8b982c 100644 --- a/lib/utils/services/live_location_tracking/live_location_tracking_service.dart +++ b/lib/utils/services/live_location_tracking/live_location_tracking_service.dart @@ -62,14 +62,13 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { startedAt: startedAt, ), ); - final name = await _nameResolver.resolveName( - config.target.entityType, - config.target.id, - ); + // Persist the recoverable "interrupted" record and subscribe to GPS + // before doing anything network-bound: a force-kill or a race with + // stop()/logout must never leave this critical path gated on the + // (possibly slow or offline) entity-name lookup below. await _store.write( LastTrackingRecord( configJson: config.toJson(), - targetName: name, startedAt: startedAt, endReason: TrackingEndReason.interrupted, ), @@ -86,6 +85,33 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { () => _finish(TrackingEndReason.maxDuration), ); } + unawaited(_resolveAndPatchTargetName(config, startedAt)); + } + + /// Resolves the human-readable target name off the critical path and + /// patches it into the persisted record once known. Guarded against the + /// session having been stopped/replaced while the (network) lookup was in + /// flight, so a late resolution can never resurrect a stopped session's + /// record or re-subscribe GPS after stop()/logout. + Future _resolveAndPatchTargetName( + LiveTrackingConfig config, + DateTime startedAt, + ) async { + final name = await _nameResolver.resolveName( + config.target.entityType, + config.target.id, + ); + if (name == null) { + return; + } + if (_session?.startedAt != startedAt) { + return; + } + final existing = await _store.read(); + if (existing == null) { + return; + } + await _store.write(existing.copyWith(targetName: name)); } @override diff --git a/test/modules/location_tracking/live_tracking_bar_test.dart b/test/modules/location_tracking/live_tracking_bar_test.dart index 39a2de3a..cbf6ab36 100644 --- a/test/modules/location_tracking/live_tracking_bar_test.dart +++ b/test/modules/location_tracking/live_tracking_bar_test.dart @@ -107,7 +107,9 @@ void main() { ) .first; final material = tester.widget(collapsedMaterialFinder); - expect(material.color, isNotNull); + final colors = + Theme.of(tester.element(collapsedMaterialFinder)).colorScheme; + expect(material.color, colors.primaryContainer); final size = tester.getSize(collapsedMaterialFinder); expect( size.width, diff --git a/test/modules/location_tracking/live_tracking_page_test.dart b/test/modules/location_tracking/live_tracking_page_test.dart index 2ddc738e..37da2927 100644 --- a/test/modules/location_tracking/live_tracking_page_test.dart +++ b/test/modules/location_tracking/live_tracking_page_test.dart @@ -97,6 +97,6 @@ void main() { await tester.pumpAndSettle(); expect(find.text('Start again'), findsOneWidget); - expect(find.text('My Tracker'), findsWidgets); + expect(find.text('My Tracker'), findsOneWidget); }); } diff --git a/test/utils/services/live_location_tracking/live_location_tracking_service_test.dart b/test/utils/services/live_location_tracking/live_location_tracking_service_test.dart index 05f2fb68..ffba6006 100644 --- a/test/utils/services/live_location_tracking/live_location_tracking_service_test.dart +++ b/test/utils/services/live_location_tracking/live_location_tracking_service_test.dart @@ -60,8 +60,19 @@ class FakeStore implements ILiveTrackingStore { class FakeNameResolver implements IEntityNameResolver { String? name = 'My Tracker'; + /// When set, [resolveName] returns this completer's future instead of + /// resolving immediately, letting tests control exactly when the + /// (network) name lookup completes relative to other service calls. + Completer? pendingCompleter; + @override - Future resolveName(String entityType, String id) async => name; + Future resolveName(String entityType, String id) { + final pending = pendingCompleter; + if (pending != null) { + return pending.future; + } + return Future.value(name); + } } class FakeRemote implements ILiveTrackingRemote { @@ -285,12 +296,58 @@ void main() { test('start writes an interrupted record with the resolved name', () async { await service.start(const LiveTrackingConfig(target: target)); + // Name resolution now happens off the critical path (see finding #1); + // let its fire-and-forget patch land before asserting on it. + await pumpEventQueue(); expect(store.record, isNotNull); expect(store.record!.targetName, 'My Tracker'); expect(store.record!.endReason, TrackingEndReason.interrupted); expect(store.record!.endedAt, isNull); }); + test('a name resolution that completes after stop() does not resurrect the ' + 'stopped session, its subscription, or its persisted record', () async { + final resolverCompleter = Completer(); + nameResolver.pendingCompleter = resolverCompleter; + + unawaited(service.start(const LiveTrackingConfig(target: target))); + await pumpEventQueue(); + + // The interrupted record and GPS subscription must already exist + // before the name ever resolves: resolveName must not gate them. + expect(store.record, isNotNull); + expect(store.record!.endReason, TrackingEndReason.interrupted); + expect(store.record!.targetName, isNull); + expect(location.controller?.hasListener, true); + + await service.stop(); + await pumpEventQueue(); + + expect(service.session, isNull); + expect(store.record!.endReason, TrackingEndReason.manual); + expect(location.controller?.hasListener, false); + + resolverCompleter.complete('Late Name'); + await pumpEventQueue(); + + expect( + service.session, + isNull, + reason: 'a late name resolution must not resurrect the session', + ); + expect( + store.record!.endReason, + TrackingEndReason.manual, + reason: 'a late name resolution must not overwrite the ended record', + ); + expect(store.record!.targetName, isNot('Late Name')); + expect( + location.controller?.hasListener, + false, + reason: 'a late name resolution must not resurrect the subscription', + ); + }); + test('stop updates the record with manual end reason and counts', () async { await service.start(const LiveTrackingConfig(target: target)); location.controller!.add(LocationSuccess(fix)); From d16ab69428f22ba49f385b735f1b90f98390457d Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 22 Jul 2026 21:34:32 +0300 Subject: [PATCH 28/40] fix(location): re-check session identity after store read in name patch --- .../live_location_tracking_service.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/lib/utils/services/live_location_tracking/live_location_tracking_service.dart b/lib/utils/services/live_location_tracking/live_location_tracking_service.dart index cb8b982c..6179ff85 100644 --- a/lib/utils/services/live_location_tracking/live_location_tracking_service.dart +++ b/lib/utils/services/live_location_tracking/live_location_tracking_service.dart @@ -111,6 +111,12 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { if (existing == null) { return; } + // Re-check after the read() await: stop()/logout may have finished (and + // cleared the store) while we were reading, and we must not resurrect a + // stopped session's record with a late write. + if (_session?.startedAt != startedAt) { + return; + } await _store.write(existing.copyWith(targetName: name)); } From 775108a443db144c921f9790a48c52a0f18bc0a2 Mon Sep 17 00:00:00 2001 From: ababak Date: Thu, 23 Jul 2026 08:32:49 +0300 Subject: [PATCH 29/40] docs: add browser-side location save widget action design spec --- ...2026-07-23-browser-location-save-design.md | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-23-browser-location-save-design.md diff --git a/docs/superpowers/specs/2026-07-23-browser-location-save-design.md b/docs/superpowers/specs/2026-07-23-browser-location-save-design.md new file mode 100644 index 00000000..a9eeecef --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-browser-location-save-design.md @@ -0,0 +1,105 @@ +# Browser-side Location Save (widget action) — Design Spec + +**Status:** Design approved, ready for implementation planning. +**Repo:** `thingsboard` / `ui-ngx` (Angular frontend). **This feature does not touch the mobile Flutter app or the backend** — it is a new frontend widget action built on existing telemetry-save HTTP APIs. +**Origin:** Follow-up carried over from `2026-07-22-gps-live-tracking-ux-design.md` ("Out of scope / follow-ups → Browser-side location save"). + +## Goal + +Add a new **generic** widget action, `saveBrowserLocation`, that reads the browser's current geolocation once (on user trigger) and writes the coordinates (and browser-meaningful metrics) to a configurable target entity via the existing attribute/telemetry save APIs. + +## Scope + +- **In:** a new `WidgetActionType.saveBrowserLocation` offered on any widget action; its config editor; runtime dispatch + geolocation capture + save; localized toasts. +- **Out:** map-widget-specific placement (this is not `placeMapItem`), continuous/watch tracking, multi-entity save, mobile app, backend/enum changes. See "Out of scope" below. + +## Architecture + +A new member of the existing `WidgetActionType` enum (`ui-ngx/src/app/shared/models/widget.models.ts:611-621`), handled generically like `custom` / `mobileAction` (i.e. **kept in** the generic `widgetActionTypes` list, unlike `placeMapItem` which is filtered out at `widget.models.ts:669-670`). No map or backend involvement. + +Three units, each independently understandable: + +1. **Config model + editor** — a `saveBrowserLocation` config object on `WidgetAction`, plus an editor sub-form under `ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/`. +2. **Geolocation helper** — a small, pure-ish service wrapping `navigator.geolocation.getCurrentPosition` as a Promise/Observable with a secure-context guard and typed error mapping. Nothing like this exists in the codebase today (greenfield). +3. **Dispatch handler** — a `saveBrowserLocation(...)` method wired into the action switch in `ui-ngx/src/app/modules/home/components/widget/widget.component.ts` (~`:1160-1228`), reusing the existing target-entity resolver and save path. + +## Capture model + +**One-shot on trigger.** A single `navigator.geolocation.getCurrentPosition(success, error, options)` per action invocation, with `options = { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }`. No `watchPosition`, no retry loop in v1. + +## Target-entity model (reuse existing) + +Reuse the mobile-action target model verbatim — the four modes already implemented by `resolveMobileActionTargetEntity(...)` at `widget.component.ts:1573-1621` (backed by `MobileActionTargetEntityType`): + +- **currentEntity** — the widget's active entity, via `getActiveEntityInfo()` (`widget.component.ts:1910-1922`). +- **currentUser** — `{ entityType: USER, id: authUser.userId }`, via `currentUserEntityId()` (`:1623-1626`). +- **entityAlias** — resolved through `AliasController` (`getEntityAliasId` → `resolveSingleEntityInfo`). +- **fromAttribute** — an `{entityType, id}` parsed from an attribute on the current entity or current user (`parseTargetEntityAttributeValue`, `:1628+`). + +The action config carries the same target descriptor shape the mobile action uses (target type + optional alias name + optional attribute source/key). The dispatch handler resolves it with the existing resolver rather than a parallel implementation. + +## Save destination (configurable data keys) + +Coordinates and metrics are written through configurable **data keys**, mirroring how map widgets define `xKey`/`yKey` and how `TbMap.saveItemData` (`map.ts:1303-1340`) batches by key type. + +**Required keys** (always saved): + +| Field | Default key name | Source | +|---|---|---| +| latitude | `latitude` | `coords.latitude` (degrees) | +| longitude | `longitude` | `coords.longitude` (degrees) | + +**Optional keys** (v1 — each individually enable-able in the editor; browser-meaningful `GeolocationCoordinates` / `GeolocationPosition` fields): + +| Field | Default key name | Source | Typical availability | +|---|---|---|---| +| accuracy | `accuracy` | `coords.accuracy` (m, horizontal) | always present | +| altitude | `altitude` | `coords.altitude` (m) | often null on non-GPS/desktop | +| altitudeAccuracy | `altitudeAccuracy` | `coords.altitudeAccuracy` (m) | null when altitude null | +| heading | `heading` | `coords.heading` (deg) | null/NaN when stationary or desktop | +| speed | `speed` | `coords.speed` (m/s) | null when stationary or desktop | +| timestamp | `locationTimestamp` | `position.timestamp` (epoch ms) | always present | + +**Per-key type/scope:** each configured key has a type — **attribute** (`SERVER_SCOPE` default, or `SHARED_SCOPE`) or **timeseries** (`LATEST_TELEMETRY`). Default for every key is `SERVER_SCOPE` attribute. + +**Null-handling (important):** `AttributeService.saveEntityAttributes` treats a `null` value as a **delete** of that key (`attribute.service.ts:71-96`). Therefore optional metrics whose captured value is `null`/`undefined`/`NaN` (e.g. `altitude`/`heading`/`speed` on a desktop) MUST be **omitted from the save payload entirely**, never sent as null — otherwise a stale attribute would be silently deleted. Required latitude/longitude are always numeric and always saved. + +**Save mechanism:** batch the enabled keys by resolved type into two payloads and call: +- `AttributeService.saveEntityAttributes(entityId, scope, AttributeData[])` (`attribute.service.ts:71`) for attribute keys, and +- `AttributeService.saveEntityTimeseries(entityId, LatestTelemetry.LATEST_TELEMETRY, AttributeData[])` (`:98`) for timeseries keys. + +## Runtime flow + +Dispatched from the action switch in `widget.component.ts`: + +1. **Secure-context guard:** if `!window.isSecureContext || !navigator.geolocation` → show the insecure-context error toast and stop (the Geolocation API is unavailable on non-HTTPS origins). +2. `getCurrentPosition(success, error, { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 })`. +3. **On success:** resolve the target entity (existing resolver) → build the attribute/timeseries batches from the enabled, non-null fields → save → **success toast**. If the target entity cannot be resolved, show the save-failed (or a target-unresolved) toast. +4. **On geolocation error:** map `error.code` to a specific localized toast: `PERMISSION_DENIED`, `POSITION_UNAVAILABLE`, `TIMEOUT`. +5. **On save (HTTP) error:** save-failed toast. + +## Feedback (toasts only) + +Localized strings added to `ui-ngx/src/assets/locale/locale.constant-en_US.json` (English only; other locales fall back to English per repo convention). Distinct messages for: success, insecure-context, permission-denied, position-unavailable, timeout, save-failed. **No** post-save custom-function hook (unlike `placeMapItem`'s `afterPlaceItemCallback`). + +## Files touched (all in `thingsboard/ui-ngx`) + +- `src/app/shared/models/widget.models.ts` — add `WidgetActionType.saveBrowserLocation`, its `widgetActionTypeTranslationMap` entry, and the config interface fields on `WidgetAction`; ensure it stays in the generic `widgetActionTypes` list. +- `src/app/modules/home/components/widget/widget.component.ts` — add the dispatch `case` and a `saveBrowserLocation(...)` handler; reuse `resolveMobileActionTargetEntity`, `getActiveEntityInfo`, `currentUserEntityId`. +- Action editor components under `src/app/modules/home/components/widget/lib/settings/common/action/` (e.g. `widget-action.component.*`) — a config sub-form: target-entity selector (reuse the mobile-action target sub-form/component if one exists) + required latitude/longitude key rows + optional metric key rows. +- `src/assets/locale/locale.constant-en_US.json` — new i18n keys. +- New geolocation helper (e.g. `src/app/core/services/browser-geolocation.service.ts`) — wraps `getCurrentPosition` + secure-context guard + typed error mapping; the error→message mapping and the field→key batching are written as pure functions so they are inspectable and (if the repo ever adds coverage) unit-testable. + +## Out of scope / follow-ups + +- Continuous / `watchPosition` tracking with a stop control. +- Multi-entity save (write one capture to several targets). +- Per-key advanced telemetry options beyond attribute-scope / latest-timeseries. +- Any mobile-app or backend change. + +## Global constraints (for the implementation plan) + +- Repo `thingsboard`, branch `feat/gps-tracker` (same branch the phase-1d Task 1 bundle-page change lives on). Leave the pre-existing uncommitted `ui-ngx/proxy.conf.js` change alone. +- Conventional Commits; **no** `Co-Authored-By` lines. +- ui-ngx verification: `cd ui-ngx && npx tsc --noEmit -p src/tsconfig.app.json` must exit 0 (only pre-existing photoswipe errors acceptable). Follow the repo convention of **no new test scaffolding**. +- User-facing strings localized in `locale.constant-en_US.json` only. From a1fb45b9d296a0bf9567ff7a4e4d3569e63a8529 Mon Sep 17 00:00:00 2001 From: ababak Date: Thu, 23 Jul 2026 08:46:30 +0300 Subject: [PATCH 30/40] docs: browser-location-save plan + spec save-as reuse update --- .../plans/2026-07-23-browser-location-save.md | 833 ++++++++++++++++++ ...2026-07-23-browser-location-save-design.md | 8 +- 2 files changed, 837 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-23-browser-location-save.md diff --git a/docs/superpowers/plans/2026-07-23-browser-location-save.md b/docs/superpowers/plans/2026-07-23-browser-location-save.md new file mode 100644 index 00000000..0ecd42ea --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-browser-location-save.md @@ -0,0 +1,833 @@ +# Browser-side Location Save (widget action) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a new generic `saveBrowserLocation` widget action that reads the browser's current geolocation once on trigger and saves the coordinates (plus optional browser metrics) to a resolvable target entity. + +**Architecture:** A new `WidgetActionType.saveBrowserLocation` handled in `WidgetComponent`'s action switch. It reuses the existing mobile-action target-entity model (`MobileActionTargetEntityConfig` + `MobileActionSaveAs`) and the existing save path (`AttributeService`). A new small `BrowserGeolocationService` wraps `navigator.geolocation` (greenfield — nothing like it exists). A new editor component mirrors the mobile-action editor's target-entity sub-form. Web-only: no backend, no mobile-app, no map involvement. + +**Tech Stack:** Angular 18 (ui-ngx), RxJS, Angular reactive forms + ControlValueAccessor, `@ngx-translate`. + +## Global Constraints + +- Repo: `thingsboard` at `/home/artem/projects/thingsboard`, branch `feat/gps-tracker` (the branch that already carries the phase-1d bundle-page change). All work is under `ui-ngx/`. Leave the pre-existing uncommitted `ui-ngx/proxy.conf.js` change alone. +- Conventional Commits; **no** `Co-Authored-By` lines. +- Verification per task (repo convention — **no test scaffolding in ui-ngx**): `cd /home/artem/projects/thingsboard/ui-ngx && npx tsc --noEmit -p src/tsconfig.app.json` must exit 0 (only any pre-existing photoswipe errors are acceptable). +- User-facing strings are localized in `ui-ngx/src/assets/locale/locale.constant-en_US.json` only (other locales fall back to English). +- Reuse existing enums/translations rather than duplicating: `MobileActionTargetEntityType`, `MobileActionAttributeSource`, `MobileActionSaveAs`, and their `*TranslationMap`s. +- The wire/type string is exactly `saveBrowserLocation` (enum member name === value). +- Spec: `docs/superpowers/specs/2026-07-23-browser-location-save-design.md` (in the flutter repo, alongside the other GPS specs). + +--- + +### Task 1: Model — enum member, descriptor interface, config field, translation + +**Files:** +- Modify: `ui-ngx/src/app/shared/models/widget.models.ts` (enum ~L611-621; `widgetActionTypeTranslationMap` ~L672-684; `WidgetAction` ~L887-911; add the new interface near the mobile save-location descriptors ~L824-862) + +**Interfaces:** +- Consumes: `MobileActionTargetEntityConfig` (`widget.models.ts:816-822`), `MobileActionSaveAs` (`:804-807`). +- Produces: `WidgetActionType.saveBrowserLocation`; `SaveBrowserLocationDescriptor`; `WidgetAction.saveBrowserLocation?: SaveBrowserLocationDescriptor`. Consumed by Tasks 3 (dispatch) and 5 (editor). + +- [ ] **Step 1: Add the enum member** + +In `widget.models.ts`, add `saveBrowserLocation` to `WidgetActionType` (after `placeMapItem`): + +```ts +export enum WidgetActionType { + doNothing = 'doNothing', + openDashboardState = 'openDashboardState', + updateDashboardState = 'updateDashboardState', + openDashboard = 'openDashboard', + custom = 'custom', + customPretty = 'customPretty', + mobileAction = 'mobileAction', + openURL = 'openURL', + placeMapItem = 'placeMapItem', + saveBrowserLocation = 'saveBrowserLocation' +} +``` + +(`saveBrowserLocation` is NOT filtered out of `widgetActionTypes` — unlike `placeMapItem` — so it becomes user-selectable automatically.) + +- [ ] **Step 2: Add the translation-map entry** + +Add to `widgetActionTypeTranslationMap` (after the `placeMapItem` entry): + +```ts + [ WidgetActionType.placeMapItem, 'widget-action.place-map-item' ], + [ WidgetActionType.saveBrowserLocation, 'widget-action.save-browser-location' ], +``` + +- [ ] **Step 3: Add the descriptor interface** + +Add near the existing `SaveLocationDescriptor`/`StartLiveLocationDescriptor` block (~L824-862): + +```ts +export interface SaveBrowserLocationDescriptor { + targetEntity?: MobileActionTargetEntityConfig; + saveAs?: MobileActionSaveAs; + latitudeKey?: string; + longitudeKey?: string; + accuracyKey?: string; + altitudeKey?: string; + altitudeAccuracyKey?: string; + headingKey?: string; + speedKey?: string; + timestampKey?: string; +} +``` + +- [ ] **Step 4: Add the config field to `WidgetAction`** + +In `interface WidgetAction` (near `mobileAction?`/`mapItemType?`, ~L908-911), add: + +```ts + mobileAction?: WidgetMobileActionDescriptor; + url?: string; + mapItemType?: MapItemType; + mapItemTooltips?: MapItemTooltips; + saveBrowserLocation?: SaveBrowserLocationDescriptor; +``` + +- [ ] **Step 5: Verify + commit** + +```bash +cd /home/artem/projects/thingsboard/ui-ngx && npx tsc --noEmit -p src/tsconfig.app.json ; echo "exit: $?" +``` +Expected: exit 0 (only any pre-existing photoswipe errors). + +```bash +cd /home/artem/projects/thingsboard +git add ui-ngx/src/app/shared/models/widget.models.ts +git commit -m "feat(widget): add saveBrowserLocation widget action type and descriptor" +``` + +--- + +### Task 2: i18n keys + +**Files:** +- Modify: `ui-ngx/src/assets/locale/locale.constant-en_US.json` (the `"widget-action"` block starts at L7685; its nested `"mobile"` object closes at ~L7796) + +**Interfaces:** +- Produces: translation keys `widget-action.save-browser-location` (the type label) and the `widget-action.browser-location.*` object (toasts, geolocation errors, optional-key labels). Consumed by Tasks 3 and 5. The service in Task 3 hard-codes the `widget-action.browser-location.error-*` keys, so the names must match exactly. + +- [ ] **Step 1: Add the type label + the `browser-location` sub-object** + +Inside the top-level `"widget-action"` object (a good spot is immediately after the `"mobile": { ... }` object closes at ~L7796), add the type label and a sibling namespace object. Match the file's 2-space-incremental indentation and trailing-comma style: + +```json + "save-browser-location": "Save browser location", + "browser-location": { + "location-saved": "Browser location saved", + "location-save-failed": "Failed to save browser location: {{error}}", + "error-insecure-context": "Browser location requires a secure (HTTPS) connection", + "error-permission-denied": "Location permission was denied", + "error-position-unavailable": "Current location is unavailable", + "error-timeout": "Timed out while getting the current location", + "optional-key-hint": "Leave blank to skip saving this value", + "accuracy-key": "Accuracy key", + "altitude-key": "Altitude key", + "altitude-accuracy-key": "Altitude accuracy key", + "heading-key": "Heading key", + "speed-key": "Speed key", + "timestamp-key": "Timestamp key" + }, +``` + +(The editor in Task 5 reuses the existing `widget-action.mobile.*` labels for target-entity/save-as/latitude/longitude, so those are not re-added here.) + +- [ ] **Step 2: Verify the JSON is well-formed + commit** + +```bash +cd /home/artem/projects/thingsboard/ui-ngx && node -e "JSON.parse(require('fs').readFileSync('src/assets/locale/locale.constant-en_US.json','utf8')); console.log('valid json')" +``` +Expected: `valid json`. + +```bash +cd /home/artem/projects/thingsboard +git add ui-ngx/src/assets/locale/locale.constant-en_US.json +git commit -m "feat(widget): add i18n for saveBrowserLocation action" +``` + +--- + +### Task 3: Browser geolocation service + +**Files:** +- Create: `ui-ngx/src/app/core/services/browser-geolocation.service.ts` + +**Interfaces:** +- Produces: `BrowserGeolocationService.getCurrentPosition(): Observable` (root-provided); `BrowserGeolocationError` (carries a `BrowserGeolocationErrorType`); `browserGeolocationErrorMessageKey(error): string` (pure — maps to a `widget-action.browser-location.error-*` i18n key). Consumed by Task 4. + +- [ ] **Step 1: Create the service** + +Create `ui-ngx/src/app/core/services/browser-geolocation.service.ts`: + +```ts +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +export enum BrowserGeolocationErrorType { + insecureContext = 'insecureContext', + permissionDenied = 'permissionDenied', + positionUnavailable = 'positionUnavailable', + timeout = 'timeout' +} + +export class BrowserGeolocationError extends Error { + constructor(public readonly errorType: BrowserGeolocationErrorType) { + super(errorType); + } +} + +const browserGeolocationErrorMessageKeys: {[key in BrowserGeolocationErrorType]: string} = { + [BrowserGeolocationErrorType.insecureContext]: 'widget-action.browser-location.error-insecure-context', + [BrowserGeolocationErrorType.permissionDenied]: 'widget-action.browser-location.error-permission-denied', + [BrowserGeolocationErrorType.positionUnavailable]: 'widget-action.browser-location.error-position-unavailable', + [BrowserGeolocationErrorType.timeout]: 'widget-action.browser-location.error-timeout' +}; + +export function browserGeolocationErrorMessageKey(error: unknown): string { + const errorType = error instanceof BrowserGeolocationError + ? error.errorType + : BrowserGeolocationErrorType.positionUnavailable; + return browserGeolocationErrorMessageKeys[errorType]; +} + +function toBrowserGeolocationError(error: GeolocationPositionError): BrowserGeolocationError { + switch (error.code) { + case error.PERMISSION_DENIED: + return new BrowserGeolocationError(BrowserGeolocationErrorType.permissionDenied); + case error.TIMEOUT: + return new BrowserGeolocationError(BrowserGeolocationErrorType.timeout); + default: + return new BrowserGeolocationError(BrowserGeolocationErrorType.positionUnavailable); + } +} + +@Injectable({ + providedIn: 'root' +}) +export class BrowserGeolocationService { + + getCurrentPosition(): Observable { + return new Observable((subscriber) => { + if (!window.isSecureContext || !navigator.geolocation) { + subscriber.error(new BrowserGeolocationError(BrowserGeolocationErrorType.insecureContext)); + return; + } + navigator.geolocation.getCurrentPosition( + (position) => { + subscriber.next(position); + subscriber.complete(); + }, + (error) => subscriber.error(toBrowserGeolocationError(error)), + { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 } + ); + }); + } +} +``` + +- [ ] **Step 2: Verify + commit** + +```bash +cd /home/artem/projects/thingsboard/ui-ngx && npx tsc --noEmit -p src/tsconfig.app.json ; echo "exit: $?" +``` +Expected: exit 0. + +```bash +cd /home/artem/projects/thingsboard +git add ui-ngx/src/app/core/services/browser-geolocation.service.ts +git commit -m "feat(widget): add browser geolocation service" +``` + +--- + +### Task 4: Dispatch handler in WidgetComponent + +**Files:** +- Modify: `ui-ngx/src/app/modules/home/components/widget/widget.component.ts` (imports; constructor ~L227; action switch ~L1214-1228; resolver ~L1573; add the new handler) + +**Interfaces:** +- Consumes: `BrowserGeolocationService`/`BrowserGeolocationError`/`browserGeolocationErrorMessageKey` (Task 3), `SaveBrowserLocationDescriptor`/`MobileActionSaveAs`/`MobileActionTargetEntityConfig` (Task 1), `AttributeService` (via `widgetContext`), `LatestTelemetry`/`AttributeScope`/`AttributeData`. +- Produces: runtime handling of `WidgetActionType.saveBrowserLocation`. The existing `resolveMobileActionTargetEntity` is refactored to delegate to a new generic `resolveActionTargetEntity(target, currentEntityId)` — behavior for the mobile action is unchanged. + +- [ ] **Step 1: Add imports** + +Add the new service import (group with other `@core` service imports): + +```ts +import { BrowserGeolocationError, browserGeolocationErrorMessageKey, BrowserGeolocationService } from '@core/services/browser-geolocation.service'; +``` + +Add `SaveBrowserLocationDescriptor` and `MobileActionTargetEntityConfig` to the existing import from `@shared/models/widget.models` (both live there; `MobileActionSaveAs` is already imported and used at L1531). Add `LatestTelemetry` to the existing import from `@shared/models/telemetry/telemetry.models` (`AttributeScope`, `AttributeData` are already imported/used at L1519/L1534). + +- [ ] **Step 2: Inject the service in the constructor** + +In the `WidgetComponent` constructor parameter list (near `private translate: TranslateService,` at L227), add: + +```ts + private translate: TranslateService, + private browserGeolocationService: BrowserGeolocationService, +``` + +- [ ] **Step 3: Add the dispatch case** + +In the action-type `switch` (the block ending at L1228), add a case after `mobileAction` and before the closing brace: + +```ts + case WidgetActionType.mobileAction: + const mobileAction = descriptor.mobileAction; + this.handleMobileAction($event, mobileAction, entityId, entityName, additionalParams, entityLabel); + break; + case WidgetActionType.saveBrowserLocation: + this.saveBrowserLocation(descriptor, entityId); + break; + } +``` + +- [ ] **Step 4: Refactor the target resolver to be generic (behavior-preserving)** + +Replace the header of `resolveMobileActionTargetEntity` (L1573) so it delegates to a new generic method, and rename the body's method to `resolveActionTargetEntity` taking the config directly. Concretely: keep everything from `const target = mobileAction.targetEntity;` onward, but move it into a new method whose parameter IS `target`, and make the old method a one-line delegator. The result: + +```ts + private resolveMobileActionTargetEntity(mobileAction: WidgetMobileActionDescriptor, + currentEntityId?: EntityId): Observable { + return this.resolveActionTargetEntity(mobileAction.targetEntity, currentEntityId); + } + + private resolveActionTargetEntity(target: MobileActionTargetEntityConfig, + currentEntityId?: EntityId): Observable { + const type = target?.type || MobileActionTargetEntityType.currentEntity; + switch (type) { + case MobileActionTargetEntityType.currentEntity: + if (validateEntityId(currentEntityId)) { + return of(currentEntityId); + } + return throwError(() => new Error('Widget action has no current entity')); + case MobileActionTargetEntityType.currentUser: + return of(this.currentUserEntityId()); + case MobileActionTargetEntityType.entityAlias: { + const aliasId = this.widgetContext.aliasController.getEntityAliasId(target.aliasName); + if (!aliasId) { + return throwError(() => new Error(`Entity alias '${target.aliasName}' not found in the dashboard`)); + } + return this.widgetContext.aliasController.resolveSingleEntityInfo(aliasId).pipe( + map((entity) => { + if (!entity?.id || !entity?.entityType) { + throw new Error(`Entity alias '${target.aliasName}' did not resolve to an entity`); + } + return {entityType: entity.entityType, id: entity.id} as EntityId; + }) + ); + } + case MobileActionTargetEntityType.fromAttribute: { + let sourceEntityId: EntityId; + if (target.attributeSource === MobileActionAttributeSource.currentEntity) { + if (!validateEntityId(currentEntityId)) { + return throwError(() => new Error('Widget action has no current entity')); + } + sourceEntityId = currentEntityId; + } else { + sourceEntityId = this.currentUserEntityId(); + } + return this.widgetContext.attributeService.getEntityAttributes( + sourceEntityId, AttributeScope.SERVER_SCOPE, [target.attributeKey], {ignoreErrors: true}).pipe( + map((attributes) => { + const attribute = attributes.find(a => a.key === target.attributeKey); + if (!attribute || !isDefinedAndNotNull(attribute.value)) { + throw new Error(`Attribute '${target.attributeKey}' not found on the source entity`); + } + return this.parseTargetEntityAttributeValue(attribute.value, target.defaultEntityType); + }) + ); + } + } + } +``` + +(This is the original body verbatim, only the entry point changed. `currentUserEntityId` and `parseTargetEntityAttributeValue` are unchanged.) + +- [ ] **Step 5: Add the `saveBrowserLocation` handler** + +Add this method (mirrors the existing `saveMobileActionLocation` at L1514, which is the same resolve→save→toast shape). Place it near `saveMobileActionLocation`: + +```ts + private saveBrowserLocation(descriptor: WidgetActionDescriptor, currentEntityId?: EntityId): void { + const config = descriptor.saveBrowserLocation; + if (!config) { + return; + } + defer(() => this.browserGeolocationService.getCurrentPosition()).pipe( + switchMap((position) => this.resolveActionTargetEntity(config.targetEntity, currentEntityId).pipe( + switchMap((targetEntityId) => { + const coords = position.coords; + const data: Array = []; + const addValue = (key: string | undefined, value: number | null | undefined) => { + const trimmed = (key || '').trim(); + if (trimmed.length && isDefinedAndNotNull(value) && !Number.isNaN(value)) { + data.push({key: trimmed, value}); + } + }; + addValue(config.latitudeKey || 'latitude', coords.latitude); + addValue(config.longitudeKey || 'longitude', coords.longitude); + addValue(config.accuracyKey, coords.accuracy); + addValue(config.altitudeKey, coords.altitude); + addValue(config.altitudeAccuracyKey, coords.altitudeAccuracy); + addValue(config.headingKey, coords.heading); + addValue(config.speedKey, coords.speed); + addValue(config.timestampKey, position.timestamp); + if (!data.length) { + return of(null); + } + if (config.saveAs === MobileActionSaveAs.timeseries) { + return this.widgetContext.attributeService.saveEntityTimeseries( + targetEntityId, LatestTelemetry.LATEST_TELEMETRY, data, {ignoreErrors: true}); + } + return this.widgetContext.attributeService.saveEntityAttributes( + targetEntityId, AttributeScope.SERVER_SCOPE, data, {ignoreErrors: true}); + }) + )) + ).subscribe({ + next: () => { + this.widgetContext.showSuccessToast( + this.translate.instant('widget-action.browser-location.location-saved')); + }, + error: (err) => { + if (err instanceof BrowserGeolocationError) { + this.widgetContext.showErrorToast(this.translate.instant(browserGeolocationErrorMessageKey(err))); + } else { + const message = err?.error?.message || err?.message || JSON.stringify(err); + this.widgetContext.showErrorToast( + this.translate.instant('widget-action.browser-location.location-save-failed', {error: message})); + } + } + }); + } +``` + +(Note: uses `LatestTelemetry.LATEST_TELEMETRY` for the timeseries scope — the correct value per `TbMap.saveItemData` — rather than the literal `'scope'` seen in the older `saveMobileActionLocation`. `defer`, `switchMap`, `of` are already imported in this file.) + +- [ ] **Step 6: Verify + commit** + +```bash +cd /home/artem/projects/thingsboard/ui-ngx && npx tsc --noEmit -p src/tsconfig.app.json ; echo "exit: $?" +``` +Expected: exit 0. + +```bash +cd /home/artem/projects/thingsboard +git add ui-ngx/src/app/modules/home/components/widget/widget.component.ts +git commit -m "feat(widget): handle saveBrowserLocation action at runtime" +``` + +--- + +### Task 5: Action config editor + wiring + +**Files:** +- Create: `ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/save-browser-location-action-editor.component.ts` +- Create: `ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/save-browser-location-action-editor.component.html` +- Modify: `ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts` (import + declarations ~L304-310 + exports ~L416-419) +- Modify: `ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts` (`updateActionTypeFormGroup` ~L244-348) +- Modify: `ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html` (the `ngSwitch` ~L286-289) + +**Interfaces:** +- Consumes: `SaveBrowserLocationDescriptor` (Task 1); `MobileActionTargetEntityType`/`MobileActionAttributeSource`/`MobileActionSaveAs` + their `*TranslationMap`s; `WidgetActionCallbacks` (for `fetchEntityAliases`). +- Produces: the `tb-save-browser-location-action-editor` component; the `saveBrowserLocation` control wired into `WidgetActionComponent`. The generic branch of `widgetActionUpdated` (`{...widgetActionFormGroup.value, ...actionTypeFormGroup.value}`) nests the value under `saveBrowserLocation`, matching Task 1's `WidgetAction.saveBrowserLocation`. + +- [ ] **Step 1: Create the editor component `.ts`** + +Create `save-browser-location-action-editor.component.ts`: + +```ts +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnDestroy, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { + MobileActionAttributeSource, + mobileActionAttributeSourceTranslationMap, + MobileActionSaveAs, + mobileActionSaveAsTranslationMap, + MobileActionTargetEntityType, + mobileActionTargetEntityTypeTranslationMap, + SaveBrowserLocationDescriptor, + WidgetActionCallbacks +} from '@shared/models/widget.models'; +import { Observable, Subject } from 'rxjs'; +import { map, startWith, takeUntil } from 'rxjs/operators'; + +@Component({ + selector: 'tb-save-browser-location-action-editor', + templateUrl: './save-browser-location-action-editor.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SaveBrowserLocationActionEditorComponent), + multi: true + } + ], + standalone: false +}) +export class SaveBrowserLocationActionEditorComponent implements ControlValueAccessor, OnInit, OnDestroy { + + @Input() + disabled: boolean; + + @Input() + callbacks: WidgetActionCallbacks; + + formGroup: UntypedFormGroup; + + targetEntityTypes = Object.values(MobileActionTargetEntityType); + targetEntityType = MobileActionTargetEntityType; + targetEntityTypeTranslations = mobileActionTargetEntityTypeTranslationMap; + attributeSources = Object.values(MobileActionAttributeSource); + attributeSourceTranslations = mobileActionAttributeSourceTranslationMap; + saveAsOptions = Object.values(MobileActionSaveAs); + saveAsTranslations = mobileActionSaveAsTranslationMap; + + entityAliasNames: string[] = []; + filteredEntityAliasNames: Observable; + + private destroy$ = new Subject(); + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder) {} + + ngOnInit(): void { + this.formGroup = this.fb.group({ + targetEntity: this.fb.group({ + type: [MobileActionTargetEntityType.currentEntity, []], + aliasName: [null, []], + attributeSource: [MobileActionAttributeSource.currentUser, []], + attributeKey: [null, []], + defaultEntityType: [null, []] + }), + saveAs: [MobileActionSaveAs.attributes, []], + latitudeKey: ['latitude', [Validators.required]], + longitudeKey: ['longitude', [Validators.required]], + accuracyKey: ['', []], + altitudeKey: ['', []], + altitudeAccuracyKey: ['', []], + headingKey: ['', []], + speedKey: ['', []], + timestampKey: ['', []] + }); + + this.entityAliasNames = (this.callbacks?.fetchEntityAliases?.() ?? []).map((alias) => alias.alias); + const aliasNameControl = this.formGroup.get('targetEntity.aliasName'); + this.filteredEntityAliasNames = aliasNameControl.valueChanges.pipe( + startWith(aliasNameControl.value ?? ''), + map((value: string) => (value ?? '').toLowerCase()), + map((search) => this.entityAliasNames.filter((name) => name.toLowerCase().includes(search))) + ); + + this.formGroup.get('targetEntity.type').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(() => this.updateTargetEntityValidators()); + + this.formGroup.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(() => this.propagateChange(this.formGroup.getRawValue())); + + this.updateTargetEntityValidators(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void {} + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.formGroup.disable({emitEvent: false}); + } else { + this.formGroup.enable({emitEvent: false}); + this.updateTargetEntityValidators(); + } + } + + writeValue(value?: SaveBrowserLocationDescriptor): void { + this.formGroup.patchValue({ + targetEntity: { + type: value?.targetEntity?.type || MobileActionTargetEntityType.currentEntity, + aliasName: value?.targetEntity?.aliasName ?? null, + attributeSource: value?.targetEntity?.attributeSource || MobileActionAttributeSource.currentUser, + attributeKey: value?.targetEntity?.attributeKey ?? null, + defaultEntityType: value?.targetEntity?.defaultEntityType ?? null + }, + saveAs: value?.saveAs || MobileActionSaveAs.attributes, + latitudeKey: value?.latitudeKey || 'latitude', + longitudeKey: value?.longitudeKey || 'longitude', + accuracyKey: value?.accuracyKey ?? '', + altitudeKey: value?.altitudeKey ?? '', + altitudeAccuracyKey: value?.altitudeAccuracyKey ?? '', + headingKey: value?.headingKey ?? '', + speedKey: value?.speedKey ?? '', + timestampKey: value?.timestampKey ?? '' + }, {emitEvent: false}); + this.updateTargetEntityValidators(); + } + + private updateTargetEntityValidators(): void { + const type: MobileActionTargetEntityType = this.formGroup.get('targetEntity.type').value; + const aliasName = this.formGroup.get('targetEntity.aliasName'); + const attributeKey = this.formGroup.get('targetEntity.attributeKey'); + aliasName.setValidators(type === MobileActionTargetEntityType.entityAlias ? [Validators.required] : []); + attributeKey.setValidators(type === MobileActionTargetEntityType.fromAttribute ? [Validators.required] : []); + aliasName.updateValueAndValidity({emitEvent: false}); + attributeKey.updateValueAndValidity({emitEvent: false}); + } +} +``` + +- [ ] **Step 2: Create the editor `.html`** + +Create `save-browser-location-action-editor.component.html`. The target-entity block mirrors `mobile-action-editor.component.html`'s `#targetEntityConfig` (reusing its i18n keys): + +```html +
+ +
+
{{ 'widget-action.mobile.target-entity-type' | translate }}
+ + + + {{ targetEntityTypeTranslations.get(type) | translate }} + + + +
+ @if (formGroup.get('targetEntity.type').value === targetEntityType.entityAlias) { +
+
{{ 'widget-action.mobile.target-entity-alias-name' | translate }}*
+ + + + + {{ name }} + + + +
+ } + @if (formGroup.get('targetEntity.type').value === targetEntityType.fromAttribute) { +
+
{{ 'widget-action.mobile.target-attribute-source' | translate }}
+ + + + {{ attributeSourceTranslations.get(source) | translate }} + + + +
+
+
{{ 'widget-action.mobile.target-attribute-key' | translate }}*
+ + + +
+
+
{{ 'widget-action.mobile.target-default-entity-type' | translate }}
+ + +
+ } +
+ +
+
{{ 'widget-action.mobile.save-as' | translate }}
+ + + + {{ saveAsTranslations.get(option) | translate }} + + + +
+ +
+
{{ 'widget-action.mobile.latitude-key' | translate }}
+ + + +
+
+
{{ 'widget-action.mobile.longitude-key' | translate }}
+ + + +
+ +
{{ 'widget-action.browser-location.optional-key-hint' | translate }}
+
+
{{ 'widget-action.browser-location.accuracy-key' | translate }}
+ + + +
+
+
{{ 'widget-action.browser-location.altitude-key' | translate }}
+ + + +
+
+
{{ 'widget-action.browser-location.altitude-accuracy-key' | translate }}
+ + + +
+
+
{{ 'widget-action.browser-location.heading-key' | translate }}
+ + + +
+
+
{{ 'widget-action.browser-location.speed-key' | translate }}
+ + + +
+
+
{{ 'widget-action.browser-location.timestamp-key' | translate }}
+ + + +
+
+``` + +- [ ] **Step 3: Register the component in the module** + +In `widget-settings-common.module.ts`: + +Add the import (near the other action editor imports ~L71-82): + +```ts +import { SaveBrowserLocationActionEditorComponent } from '@home/components/widget/lib/settings/common/action/save-browser-location-action-editor.component'; +``` + +Add to the `declarations` array (near `MobileActionEditorComponent` at ~L308): + +```ts + MobileActionEditorComponent, + SaveBrowserLocationActionEditorComponent, +``` + +Add to the `exports` array (near `MobileActionEditorComponent` at ~L419): + +```ts + MobileActionEditorComponent, + SaveBrowserLocationActionEditorComponent, +``` + +- [ ] **Step 4: Add the form control in `WidgetActionComponent`** + +In `widget-action.component.ts`, in `updateActionTypeFormGroup(...)`, add a case alongside `mobileAction` (~L312-317): + +```ts + case WidgetActionType.saveBrowserLocation: + this.actionTypeFormGroup.addControl( + 'saveBrowserLocation', + this.fb.control(action ? action.saveBrowserLocation : null, [Validators.required]) + ); + break; +``` + +(The generic branch of `widgetActionUpdated` already spreads `actionTypeFormGroup.value`, so the control's value is emitted under the `saveBrowserLocation` key — no change needed there.) + +- [ ] **Step 5: Render the editor in `widget-action.component.html`** + +Add a `ngSwitchCase` alongside the `mobileAction` one (~L286-289): + +```html + + + + +``` + +- [ ] **Step 6: Verify + commit** + +```bash +cd /home/artem/projects/thingsboard/ui-ngx && npx tsc --noEmit -p src/tsconfig.app.json ; echo "exit: $?" +``` +Expected: exit 0. + +```bash +cd /home/artem/projects/thingsboard +git add ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/save-browser-location-action-editor.component.ts \ + ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/save-browser-location-action-editor.component.html \ + ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts \ + ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts \ + ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html +git commit -m "feat(widget): add saveBrowserLocation action config editor" +``` + +--- + +### Task 6: Manual smoke test (verification only) + +**Files:** none. + +- [ ] **Step 1** — `yarn start` ui-ngx over HTTPS (or a secure-context dev setup). On a dashboard widget, add a widget action; confirm **"Save browser location"** appears in the action-type dropdown. +- [ ] **Step 2** — Configure it: target **Current entity**, saveAs **Attributes**, keep latitude/longitude, set `accuracy` key. Trigger the action → browser prompts for location → on allow, a success toast appears and the entity gains `latitude`/`longitude`/`accuracy` server-scope attributes. +- [ ] **Step 3** — Set saveAs **Time series** → values land as latest telemetry instead. +- [ ] **Step 4** — Target **Entity alias** and **From attribute** → saves to the resolved entity; a bad alias/attribute shows the save-failed error toast. +- [ ] **Step 5** — Deny the permission prompt → permission-denied toast. Load the dashboard over plain HTTP → insecure-context toast (no crash). +- [ ] **Step 6** — Leave optional keys (altitude/heading/speed) blank or unavailable on desktop → they are simply absent from the entity (no attributes deleted, no error). diff --git a/docs/superpowers/specs/2026-07-23-browser-location-save-design.md b/docs/superpowers/specs/2026-07-23-browser-location-save-design.md index a9eeecef..7797d378 100644 --- a/docs/superpowers/specs/2026-07-23-browser-location-save-design.md +++ b/docs/superpowers/specs/2026-07-23-browser-location-save-design.md @@ -49,7 +49,7 @@ Coordinates and metrics are written through configurable **data keys**, mirrorin | latitude | `latitude` | `coords.latitude` (degrees) | | longitude | `longitude` | `coords.longitude` (degrees) | -**Optional keys** (v1 — each individually enable-able in the editor; browser-meaningful `GeolocationCoordinates` / `GeolocationPosition` fields): +**Optional keys** (v1 — each has a key-name input in the editor; leaving a name blank means "don't save that field"; browser-meaningful `GeolocationCoordinates` / `GeolocationPosition` fields): | Field | Default key name | Source | Typical availability | |---|---|---|---| @@ -60,11 +60,11 @@ Coordinates and metrics are written through configurable **data keys**, mirrorin | speed | `speed` | `coords.speed` (m/s) | null when stationary or desktop | | timestamp | `locationTimestamp` | `position.timestamp` (epoch ms) | always present | -**Per-key type/scope:** each configured key has a type — **attribute** (`SERVER_SCOPE` default, or `SHARED_SCOPE`) or **timeseries** (`LATEST_TELEMETRY`). Default for every key is `SERVER_SCOPE` attribute. +**Save-as (reused, single setting for all keys):** one `saveAs` selector — **Attributes (server scope)** or **Time series** — applied to every saved key, reusing the existing `MobileActionSaveAs` enum and its translations (the same control the mobile "Save location to entity" action uses). Attributes are written to `SERVER_SCOPE`; time series to `LATEST_TELEMETRY`. Per-key type/scope is intentionally **not** offered (YAGNI; matches the existing mobile action). -**Null-handling (important):** `AttributeService.saveEntityAttributes` treats a `null` value as a **delete** of that key (`attribute.service.ts:71-96`). Therefore optional metrics whose captured value is `null`/`undefined`/`NaN` (e.g. `altitude`/`heading`/`speed` on a desktop) MUST be **omitted from the save payload entirely**, never sent as null — otherwise a stale attribute would be silently deleted. Required latitude/longitude are always numeric and always saved. +**Null-handling (important):** `AttributeService.saveEntityAttributes` treats a `null` value as a **delete** of that key (`attribute.service.ts:71-96`). Therefore an optional metric whose captured value is `null`/`undefined`/`NaN` (e.g. `altitude`/`heading`/`speed` on a desktop), or whose key name is blank, MUST be **omitted from the save payload entirely**, never sent as null — otherwise a stale attribute/series would be silently deleted. Required latitude/longitude are always numeric and always saved. -**Save mechanism:** batch the enabled keys by resolved type into two payloads and call: +**Save mechanism:** collect the enabled, non-null fields into one payload and call, per `saveAs`: - `AttributeService.saveEntityAttributes(entityId, scope, AttributeData[])` (`attribute.service.ts:71`) for attribute keys, and - `AttributeService.saveEntityTimeseries(entityId, LatestTelemetry.LATEST_TELEMETRY, AttributeData[])` (`:98`) for timeseries keys. From 288694ee22c2b4e63e422a78428b4755867898eb Mon Sep 17 00:00:00 2001 From: ababak Date: Tue, 28 Jul 2026 11:05:22 +0300 Subject: [PATCH 31/40] feat(location): drive live tracking saves from the configured key mapping The dashboard now sends the keys to save rather than a fixed pair plus flags: each entry names a location value, the entity key it is written under and whether it lands in server attributes or time series. Replaces latitudeKey, longitudeKey, includeMetadata, mirrorToAttributes and writeStatusAttributes, drops the redundant gpsLastUpdateTime attribute, and switches the maximum duration to seconds to match the wire config. --- .../live_location_tracking_service.dart | 92 +++++---- .../model/live_tracking_config.dart | 122 +++++++++--- .../live_tracking_bar_test.dart | 14 ++ .../live_tracking_page_test.dart | 3 + .../last_tracking_record_test.dart | 3 + .../live_location_tracking_service_test.dart | 181 +++++++++++------- .../live_tracking_config_test.dart | 118 +++++++++--- .../live_tracking_store_test.dart | 3 + .../live_location_actions_test.dart | 12 +- 9 files changed, 392 insertions(+), 156 deletions(-) diff --git a/lib/utils/services/live_location_tracking/live_location_tracking_service.dart b/lib/utils/services/live_location_tracking/live_location_tracking_service.dart index 6179ff85..76c571e8 100644 --- a/lib/utils/services/live_location_tracking/live_location_tracking_service.dart +++ b/lib/utils/services/live_location_tracking/live_location_tracking_service.dart @@ -73,15 +73,12 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { endReason: TrackingEndReason.interrupted, ), ); - await _writeStatusAttributes({ - 'gpsActive': true, - if (config.trackedBy != null) 'gpsTrackedBy': config.trackedBy, - }); + await _writeTrackingStatus(active: true, includeTrackedBy: true); _subscribe(config); - final maxDuration = config.maxDurationMinutes; + final maxDuration = config.maxDurationSeconds; if (maxDuration != null) { _maxDurationTimer = Timer( - Duration(minutes: maxDuration), + Duration(seconds: maxDuration), () => _finish(TrackingEndReason.maxDuration), ); } @@ -129,7 +126,7 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { _cancelSubscription(); final current = _session; if (current != null) { - await _writeStatusAttributes({'gpsActive': false}); + await _writeTrackingStatus(active: false); await _updateRecordOnEnd(current, reason); _setSession(null); } @@ -165,7 +162,7 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { } _cancelSubscription(); _setSession(current.copyWith(status: LiveTrackingStatus.paused)); - await _writeStatusAttributes({'gpsActive': false}); + await _writeTrackingStatus(active: false); } @override @@ -177,7 +174,7 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { _setSession( current.copyWith(status: LiveTrackingStatus.tracking, lastError: null), ); - await _writeStatusAttributes({'gpsActive': true}); + await _writeTrackingStatus(active: true, includeTrackedBy: true); _subscribe(current.config); } @@ -220,26 +217,16 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { } Future _saveFix(LiveTrackingConfig config, GeoPosition position) async { - final values = { - config.latitudeKey: position.latitude, - config.longitudeKey: position.longitude, - if (config.includeMetadata) ...{ - 'gpsAccuracy': position.accuracy, - if (position.altitude != null) 'gpsAltitude': position.altitude, - if (position.speed != null) 'gpsSpeed': position.speed, - if (position.heading != null) 'gpsHeading': position.heading, - }, - }; final ts = (position.timestamp ?? DateTime.now()).millisecondsSinceEpoch; try { - await _remote.saveTelemetry(config.target, ts, values); - final attributes = { - if (config.mirrorToAttributes) ...values, - if (config.writeStatusAttributes) 'gpsLastUpdateTime': ts, - }; - if (attributes.isNotEmpty) { - await _remote.saveAttributes(config.target, attributes); - } + await _save(config, { + LiveTrackingKeyType.latitude: position.latitude, + LiveTrackingKeyType.longitude: position.longitude, + LiveTrackingKeyType.accuracy: position.accuracy, + LiveTrackingKeyType.altitude: position.altitude, + LiveTrackingKeyType.speed: position.speed, + LiveTrackingKeyType.heading: position.heading, + }, ts: ts); final current = _session; if (current != null) { _setSession(current.copyWith(savedCount: current.savedCount + 1)); @@ -267,7 +254,7 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { _setSession( current.copyWith(status: LiveTrackingStatus.paused, lastError: message), ); - await _writeStatusAttributes({'gpsActive': false}); + await _writeTrackingStatus(active: false); } /// Fire-and-forget teardown: [StreamSubscription.cancel] detaches the @@ -278,15 +265,56 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { _subscription = null; } - Future _writeStatusAttributes(Map attributes) async { + Future _writeTrackingStatus({ + required bool active, + bool includeTrackedBy = false, + }) async { final config = _session?.config; - if (config == null || !config.writeStatusAttributes) { + if (config == null) { return; } try { - await _remote.saveAttributes(config.target, attributes); + await _save(config, { + LiveTrackingKeyType.gpsActive: active, + if (includeTrackedBy) + LiveTrackingKeyType.gpsTrackedBy: config.trackedBy, + }); } catch (e, s) { - _log.error('LiveLocationTrackingService: status attributes failed', e, s); + _log.error('LiveLocationTrackingService: tracking status failed', e, s); + } + } + + /// Routes each configured key to attributes or time series under the label + /// the dashboard resolved for it. Values the dashboard did not ask for — and + /// values the device could not provide — are skipped. + Future _save( + LiveTrackingConfig config, + Map values, { + int? ts, + }) async { + final attributes = {}; + final telemetry = {}; + for (final key in config.keys) { + final value = values[key.key]; + if (value == null) { + continue; + } + switch (key.valueType) { + case LiveTrackingValueType.attribute: + attributes[key.label] = value; + case LiveTrackingValueType.timeseries: + telemetry[key.label] = value; + } + } + if (telemetry.isNotEmpty) { + await _remote.saveTelemetry( + config.target, + ts ?? DateTime.now().millisecondsSinceEpoch, + telemetry, + ); + } + if (attributes.isNotEmpty) { + await _remote.saveAttributes(config.target, attributes); } } diff --git a/lib/utils/services/live_location_tracking/model/live_tracking_config.dart b/lib/utils/services/live_location_tracking/model/live_tracking_config.dart index b6bca443..6b2fafbc 100644 --- a/lib/utils/services/live_location_tracking/model/live_tracking_config.dart +++ b/lib/utils/services/live_location_tracking/model/live_tracking_config.dart @@ -20,21 +20,94 @@ class LiveTrackingTarget { Map toJson() => {'entityType': entityType, 'id': id}; } +/// Location values the dashboard can ask the app to save. Mirrors the +/// `LocationKey` enum on the web side. +enum LiveTrackingKeyType { + latitude('LATITUDE'), + longitude('LONGITUDE'), + accuracy('ACCURACY'), + altitude('ALTITUDE'), + speed('SPEED'), + heading('HEADING'), + gpsActive('GPS_ACTIVE'), + gpsTrackedBy('GPS_TRACKED_BY'); + + const LiveTrackingKeyType(this.wireValue); + + final String wireValue; + + static LiveTrackingKeyType? fromWireValue(String? value) { + for (final type in LiveTrackingKeyType.values) { + if (type.wireValue == value) { + return type; + } + } + return null; + } +} + +enum LiveTrackingValueType { + attribute('ATTRIBUTE'), + timeseries('TIMESERIES'); + + const LiveTrackingValueType(this.wireValue); + + final String wireValue; + + static LiveTrackingValueType fromWireValue(String? value) => + value == LiveTrackingValueType.timeseries.wireValue + ? LiveTrackingValueType.timeseries + : LiveTrackingValueType.attribute; +} + +/// One configured value: which location field to save, under which entity key, +/// and whether it lands in server attributes or time series. +class LiveTrackingKey { + const LiveTrackingKey({ + required this.key, + required this.label, + required this.valueType, + }); + + factory LiveTrackingKey.fromJson(Map json) { + final key = LiveTrackingKeyType.fromWireValue(json['key'] as String?); + final label = json['label']; + if (key == null || label is! String || label.isEmpty) { + throw const FormatException( + 'Live tracking key must contain a known key and a non-empty label', + ); + } + return LiveTrackingKey( + key: key, + label: label, + valueType: LiveTrackingValueType.fromWireValue( + json['valueType'] as String?, + ), + ); + } + + final LiveTrackingKeyType key; + final String label; + final LiveTrackingValueType valueType; + + Map toJson() => { + 'key': key.wireValue, + 'label': label, + 'valueType': valueType.wireValue, + }; +} + /// Fully-resolved live tracking session config received from the dashboard -/// (the web side resolves aliases/attributes to a concrete target entity -/// before sending — see the phase 1c wire protocol in the plan doc). +/// (the web side resolves aliases/attributes to a concrete target entity and +/// key labels to their effective names before sending). class LiveTrackingConfig { const LiveTrackingConfig({ required this.target, - this.latitudeKey = 'latitude', - this.longitudeKey = 'longitude', - this.includeMetadata = false, - this.mirrorToAttributes = false, + required this.keys, this.accuracy = LocationAccuracyLevel.balanced, this.distanceFilterMeters, this.intervalSeconds, - this.maxDurationMinutes, - this.writeStatusAttributes = true, + this.maxDurationSeconds, this.trackedBy, }); @@ -43,33 +116,34 @@ class LiveTrackingConfig { if (targetJson is! Map) { throw const FormatException('Live tracking config is missing target'); } + final keysJson = json['keys']; + if (keysJson is! List || keysJson.isEmpty) { + throw const FormatException('Live tracking config is missing keys'); + } return LiveTrackingConfig( target: LiveTrackingTarget.fromJson( Map.from(targetJson), ), - latitudeKey: json['latitudeKey'] as String? ?? 'latitude', - longitudeKey: json['longitudeKey'] as String? ?? 'longitude', - includeMetadata: json['includeMetadata'] as bool? ?? false, - mirrorToAttributes: json['mirrorToAttributes'] as bool? ?? false, + keys: keysJson + .map( + (key) => + LiveTrackingKey.fromJson(Map.from(key as Map)), + ) + .toList(growable: false), accuracy: _accuracyFromString(json['accuracy'] as String?), distanceFilterMeters: (json['distanceFilterMeters'] as num?)?.toInt(), intervalSeconds: (json['intervalSeconds'] as num?)?.toInt(), - maxDurationMinutes: (json['maxDurationMinutes'] as num?)?.toInt(), - writeStatusAttributes: json['writeStatusAttributes'] as bool? ?? true, + maxDurationSeconds: (json['maxDurationSeconds'] as num?)?.toInt(), trackedBy: json['trackedBy'] as String?, ); } final LiveTrackingTarget target; - final String latitudeKey; - final String longitudeKey; - final bool includeMetadata; - final bool mirrorToAttributes; + final List keys; final LocationAccuracyLevel accuracy; final int? distanceFilterMeters; final int? intervalSeconds; - final int? maxDurationMinutes; - final bool writeStatusAttributes; + final int? maxDurationSeconds; final String? trackedBy; static LocationAccuracyLevel _accuracyFromString(String? value) => @@ -81,15 +155,11 @@ class LiveTrackingConfig { Map toJson() => { 'target': target.toJson(), - 'latitudeKey': latitudeKey, - 'longitudeKey': longitudeKey, - 'includeMetadata': includeMetadata, - 'mirrorToAttributes': mirrorToAttributes, + 'keys': keys.map((key) => key.toJson()).toList(growable: false), 'accuracy': _accuracyToString(accuracy), 'distanceFilterMeters': distanceFilterMeters, 'intervalSeconds': intervalSeconds, - 'maxDurationMinutes': maxDurationMinutes, - 'writeStatusAttributes': writeStatusAttributes, + 'maxDurationSeconds': maxDurationSeconds, 'trackedBy': trackedBy, }; diff --git a/test/modules/location_tracking/live_tracking_bar_test.dart b/test/modules/location_tracking/live_tracking_bar_test.dart index cbf6ab36..1790f611 100644 --- a/test/modules/location_tracking/live_tracking_bar_test.dart +++ b/test/modules/location_tracking/live_tracking_bar_test.dart @@ -66,6 +66,13 @@ void main() { tracking.session = LiveTrackingSession( config: const LiveTrackingConfig( target: LiveTrackingTarget(entityType: 'DEVICE', id: 'd-1'), + keys: [ + LiveTrackingKey( + key: LiveTrackingKeyType.latitude, + label: 'latitude', + valueType: LiveTrackingValueType.attribute, + ), + ], ), status: LiveTrackingStatus.tracking, startedAt: DateTime.fromMillisecondsSinceEpoch(0), @@ -87,6 +94,13 @@ void main() { tracking.session = LiveTrackingSession( config: const LiveTrackingConfig( target: LiveTrackingTarget(entityType: 'DEVICE', id: 'd-1'), + keys: [ + LiveTrackingKey( + key: LiveTrackingKeyType.latitude, + label: 'latitude', + valueType: LiveTrackingValueType.attribute, + ), + ], ), status: LiveTrackingStatus.tracking, startedAt: DateTime.fromMillisecondsSinceEpoch(0), diff --git a/test/modules/location_tracking/live_tracking_page_test.dart b/test/modules/location_tracking/live_tracking_page_test.dart index 37da2927..85446e22 100644 --- a/test/modules/location_tracking/live_tracking_page_test.dart +++ b/test/modules/location_tracking/live_tracking_page_test.dart @@ -81,6 +81,9 @@ void main() { final record = LastTrackingRecord( configJson: const { 'target': {'entityType': 'DEVICE', 'id': 'd-1'}, + 'keys': [ + {'key': 'LATITUDE', 'label': 'latitude', 'valueType': 'ATTRIBUTE'}, + ], }, startedAt: DateTime.fromMillisecondsSinceEpoch(0), endedAt: DateTime.fromMillisecondsSinceEpoch(60000), diff --git a/test/utils/services/live_location_tracking/last_tracking_record_test.dart b/test/utils/services/live_location_tracking/last_tracking_record_test.dart index 17d90b95..a7907535 100644 --- a/test/utils/services/live_location_tracking/last_tracking_record_test.dart +++ b/test/utils/services/live_location_tracking/last_tracking_record_test.dart @@ -5,6 +5,9 @@ void main() { final json = { 'configJson': { 'target': {'entityType': 'DEVICE', 'id': 'd-1'}, + 'keys': [ + {'key': 'LATITUDE', 'label': 'latitude', 'valueType': 'ATTRIBUTE'}, + ], }, 'targetName': 'My Tracker', 'startedAt': 1720000000000, diff --git a/test/utils/services/live_location_tracking/live_location_tracking_service_test.dart b/test/utils/services/live_location_tracking/live_location_tracking_service_test.dart index ffba6006..eb6e7cc9 100644 --- a/test/utils/services/live_location_tracking/live_location_tracking_service_test.dart +++ b/test/utils/services/live_location_tracking/live_location_tracking_service_test.dart @@ -101,8 +101,38 @@ class FakeRemote implements ILiveTrackingRemote { } } +LiveTrackingKey _key( + LiveTrackingKeyType key, + String label, + LiveTrackingValueType valueType, +) => LiveTrackingKey(key: key, label: label, valueType: valueType); + void main() { const target = LiveTrackingTarget(entityType: 'DEVICE', id: 'd-1'); + final positionKeys = [ + _key( + LiveTrackingKeyType.latitude, + 'latitude', + LiveTrackingValueType.attribute, + ), + _key( + LiveTrackingKeyType.longitude, + 'longitude', + LiveTrackingValueType.attribute, + ), + ]; + final statusKeys = [ + _key( + LiveTrackingKeyType.gpsActive, + 'gpsActive', + LiveTrackingValueType.attribute, + ), + _key( + LiveTrackingKeyType.gpsTrackedBy, + 'gpsTrackedBy', + LiveTrackingValueType.attribute, + ), + ]; final fix = GeoPosition( latitude: 1, longitude: 2, @@ -113,6 +143,17 @@ void main() { heading: 90, ); + LiveTrackingConfig configOf({ + List? keys, + String? trackedBy, + int? maxDurationSeconds, + }) => LiveTrackingConfig( + target: target, + keys: keys ?? positionKeys, + trackedBy: trackedBy, + maxDurationSeconds: maxDurationSeconds, + ); + late FakeLocationService location; late FakeRemote remote; late FakeStore store; @@ -133,9 +174,9 @@ void main() { ); }); - test('start emits a tracking session and writes status attributes', () async { + test('start emits a tracking session and writes status keys', () async { await service.start( - const LiveTrackingConfig(target: target, trackedBy: 'me@tb.io'), + configOf(keys: [...positionKeys, ...statusKeys], trackedBy: 'me@tb.io'), ); expect(service.session?.status, LiveTrackingStatus.tracking); @@ -146,82 +187,94 @@ void main() { expect(location.lastSettings?.background, isNotNull); }); - test('start with writeStatusAttributes=false writes nothing', () async { - await service.start( - const LiveTrackingConfig(target: target, writeStatusAttributes: false), - ); + test('start without status keys configured writes nothing', () async { + await service.start(configOf(trackedBy: 'me@tb.io')); expect(remote.attributeCalls, isEmpty); }); - test( - 'fix saves telemetry with configured keys and gpsLastUpdateTime', - () async { - await service.start( - const LiveTrackingConfig( - target: target, - latitudeKey: 'lat', - longitudeKey: 'lng', - ), - ); - remote.attributeCalls.clear(); - - location.controller!.add(LocationSuccess(fix)); - await pumpEventQueue(); - - final (savedTarget, ts, values) = remote.telemetryCalls.single; - expect(savedTarget.id, 'd-1'); - expect(ts, 1720000000000); - expect(values, {'lat': 1.0, 'lng': 2.0}); - expect(remote.attributeCalls.single.$2, { - 'gpsLastUpdateTime': 1720000000000, - }); - expect(service.session?.fixCount, 1); - expect(service.session?.savedCount, 1); - expect(service.session?.lastFix, fix); - }, - ); - - test('includeMetadata adds gps metadata telemetry keys', () async { + test('fix routes each configured key to its label and value type', () async { await service.start( - const LiveTrackingConfig( - target: target, - includeMetadata: true, - writeStatusAttributes: false, + configOf( + keys: [ + _key( + LiveTrackingKeyType.latitude, + 'lat', + LiveTrackingValueType.attribute, + ), + _key( + LiveTrackingKeyType.longitude, + 'lng', + LiveTrackingValueType.attribute, + ), + _key( + LiveTrackingKeyType.accuracy, + 'gpsAccuracy', + LiveTrackingValueType.timeseries, + ), + _key( + LiveTrackingKeyType.speed, + 'gpsSpeed', + LiveTrackingValueType.timeseries, + ), + ], ), ); + remote.attributeCalls.clear(); location.controller!.add(LocationSuccess(fix)); await pumpEventQueue(); - expect(remote.telemetryCalls.single.$3, { - 'latitude': 1.0, - 'longitude': 2.0, - 'gpsAccuracy': 5.0, - 'gpsAltitude': 100.0, - 'gpsSpeed': 3.0, - 'gpsHeading': 90.0, - }); + final (savedTarget, ts, values) = remote.telemetryCalls.single; + expect(savedTarget.id, 'd-1'); + expect(ts, 1720000000000); + expect(values, {'gpsAccuracy': 5.0, 'gpsSpeed': 3.0}); + expect(remote.attributeCalls.single.$2, {'lat': 1.0, 'lng': 2.0}); + expect(service.session?.fixCount, 1); + expect(service.session?.savedCount, 1); + expect(service.session?.lastFix, fix); }); - test('mirrorToAttributes copies values into the attribute save', () async { + test('values the dashboard did not ask for are not saved', () async { await service.start( - const LiveTrackingConfig(target: target, mirrorToAttributes: true), + configOf( + keys: [ + _key( + LiveTrackingKeyType.latitude, + 'latitude', + LiveTrackingValueType.timeseries, + ), + _key( + LiveTrackingKeyType.longitude, + 'longitude', + LiveTrackingValueType.timeseries, + ), + ], + ), ); - remote.attributeCalls.clear(); location.controller!.add(LocationSuccess(fix)); await pumpEventQueue(); - expect(remote.attributeCalls.single.$2, { + expect(remote.telemetryCalls.single.$3, { 'latitude': 1.0, 'longitude': 2.0, - 'gpsLastUpdateTime': 1720000000000, }); + expect(remote.attributeCalls, isEmpty); }); test('save failure increments saveErrorCount and keeps tracking', () async { - await service.start(const LiveTrackingConfig(target: target)); + await service.start( + configOf( + keys: [ + _key( + LiveTrackingKeyType.latitude, + 'latitude', + LiveTrackingValueType.timeseries, + ), + ], + ), + ); remote.throwOnTelemetry = Exception('boom'); location.controller!.add(LocationSuccess(fix)); @@ -236,7 +289,7 @@ void main() { test( 'pause cancels the stream and writes gpsActive=false; resume restores', () async { - await service.start(const LiveTrackingConfig(target: target)); + await service.start(configOf(keys: [...positionKeys, ...statusKeys])); remote.attributeCalls.clear(); await service.pause(); @@ -253,7 +306,7 @@ void main() { ); test('stop clears the session and writes gpsActive=false', () async { - await service.start(const LiveTrackingConfig(target: target)); + await service.start(configOf(keys: [...positionKeys, ...statusKeys])); remote.attributeCalls.clear(); final emissions = []; final sub = service.sessionStream.listen(emissions.add); @@ -270,7 +323,7 @@ void main() { test( 'terminal permission failure pauses the session with an error', () async { - await service.start(const LiveTrackingConfig(target: target)); + await service.start(configOf()); location.controller!.add(const LocationPermissionDenied()); await pumpEventQueue(); @@ -280,11 +333,9 @@ void main() { }, ); - test('maxDurationMinutes auto-stops the session', () { + test('maxDurationSeconds auto-stops the session', () { fakeAsync((async) { - service.start( - const LiveTrackingConfig(target: target, maxDurationMinutes: 5), - ); + service.start(configOf(maxDurationSeconds: 300)); async.flushMicrotasks(); expect(service.session, isNotNull); @@ -295,7 +346,7 @@ void main() { }); test('start writes an interrupted record with the resolved name', () async { - await service.start(const LiveTrackingConfig(target: target)); + await service.start(configOf()); // Name resolution now happens off the critical path (see finding #1); // let its fire-and-forget patch land before asserting on it. await pumpEventQueue(); @@ -310,7 +361,7 @@ void main() { final resolverCompleter = Completer(); nameResolver.pendingCompleter = resolverCompleter; - unawaited(service.start(const LiveTrackingConfig(target: target))); + unawaited(service.start(configOf())); await pumpEventQueue(); // The interrupted record and GPS subscription must already exist @@ -349,7 +400,7 @@ void main() { }); test('stop updates the record with manual end reason and counts', () async { - await service.start(const LiveTrackingConfig(target: target)); + await service.start(configOf()); location.controller!.add(LocationSuccess(fix)); await pumpEventQueue(); @@ -364,9 +415,7 @@ void main() { test('max-duration end writes maxDuration reason', () { fakeAsync((async) { - service.start( - const LiveTrackingConfig(target: target, maxDurationMinutes: 5), - ); + service.start(configOf(maxDurationSeconds: 300)); async.flushMicrotasks(); async.elapse(const Duration(minutes: 5, seconds: 1)); async.flushMicrotasks(); diff --git a/test/utils/services/live_location_tracking/live_tracking_config_test.dart b/test/utils/services/live_location_tracking/live_tracking_config_test.dart index 0ba8c171..781d5509 100644 --- a/test/utils/services/live_location_tracking/live_tracking_config_test.dart +++ b/test/utils/services/live_location_tracking/live_tracking_config_test.dart @@ -6,61 +6,77 @@ void main() { test('parses a full config', () { final config = LiveTrackingConfig.fromJson(const { 'target': {'entityType': 'DEVICE', 'id': 'abc-123'}, - 'latitudeKey': 'lat', - 'longitudeKey': 'lng', - 'includeMetadata': true, - 'mirrorToAttributes': true, + 'keys': [ + {'key': 'LATITUDE', 'label': 'lat', 'valueType': 'ATTRIBUTE'}, + {'key': 'LONGITUDE', 'label': 'lng', 'valueType': 'ATTRIBUTE'}, + {'key': 'SPEED', 'label': 'gpsSpeed', 'valueType': 'TIMESERIES'}, + ], 'accuracy': 'HIGH', 'distanceFilterMeters': 25, 'intervalSeconds': 60, - 'maxDurationMinutes': 120, - 'writeStatusAttributes': false, + 'maxDurationSeconds': 7200, 'trackedBy': 'user@example.com', }); expect(config.target.entityType, 'DEVICE'); expect(config.target.id, 'abc-123'); - expect(config.latitudeKey, 'lat'); - expect(config.longitudeKey, 'lng'); - expect(config.includeMetadata, true); - expect(config.mirrorToAttributes, true); + expect(config.keys.length, 3); + expect(config.keys.first.key, LiveTrackingKeyType.latitude); + expect(config.keys.first.label, 'lat'); + expect(config.keys.first.valueType, LiveTrackingValueType.attribute); + expect(config.keys.last.key, LiveTrackingKeyType.speed); + expect(config.keys.last.valueType, LiveTrackingValueType.timeseries); expect(config.accuracy, LocationAccuracyLevel.high); expect(config.distanceFilterMeters, 25); expect(config.intervalSeconds, 60); - expect(config.maxDurationMinutes, 120); - expect(config.writeStatusAttributes, false); + expect(config.maxDurationSeconds, 7200); expect(config.trackedBy, 'user@example.com'); }); test('applies defaults for a minimal config', () { final config = LiveTrackingConfig.fromJson(const { 'target': {'entityType': 'USER', 'id': 'u-1'}, + 'keys': [ + {'key': 'LATITUDE', 'label': 'latitude', 'valueType': 'ATTRIBUTE'}, + ], }); - expect(config.latitudeKey, 'latitude'); - expect(config.longitudeKey, 'longitude'); - expect(config.includeMetadata, false); - expect(config.mirrorToAttributes, false); expect(config.accuracy, LocationAccuracyLevel.balanced); expect(config.distanceFilterMeters, isNull); expect(config.intervalSeconds, isNull); - expect(config.maxDurationMinutes, isNull); - expect(config.writeStatusAttributes, true); + expect(config.maxDurationSeconds, isNull); expect(config.trackedBy, isNull); }); test('unknown accuracy falls back to balanced', () { final config = LiveTrackingConfig.fromJson(const { 'target': {'entityType': 'USER', 'id': 'u-1'}, + 'keys': [ + {'key': 'LATITUDE', 'label': 'latitude', 'valueType': 'ATTRIBUTE'}, + ], 'accuracy': 'ULTRA', }); expect(config.accuracy, LocationAccuracyLevel.balanced); }); + test('unknown value type falls back to attribute', () { + final key = LiveTrackingKey.fromJson(const { + 'key': 'LATITUDE', + 'label': 'latitude', + 'valueType': 'SOMETHING_ELSE', + }); + + expect(key.valueType, LiveTrackingValueType.attribute); + }); + test('missing target throws FormatException', () { expect( - () => LiveTrackingConfig.fromJson(const {'latitudeKey': 'lat'}), + () => LiveTrackingConfig.fromJson(const { + 'keys': [ + {'key': 'LATITUDE', 'label': 'latitude', 'valueType': 'ATTRIBUTE'}, + ], + }), throwsFormatException, ); }); @@ -74,18 +90,58 @@ void main() { ); }); + test('missing keys throws FormatException', () { + expect( + () => LiveTrackingConfig.fromJson(const { + 'target': {'entityType': 'DEVICE', 'id': 'abc-123'}, + }), + throwsFormatException, + ); + }); + + test('unknown key throws FormatException', () { + expect( + () => LiveTrackingConfig.fromJson(const { + 'target': {'entityType': 'DEVICE', 'id': 'abc-123'}, + 'keys': [ + {'key': 'TEMPERATURE', 'label': 'temp', 'valueType': 'TIMESERIES'}, + ], + }), + throwsFormatException, + ); + }); + + test('key without a label throws FormatException', () { + expect( + () => LiveTrackingConfig.fromJson(const { + 'target': {'entityType': 'DEVICE', 'id': 'abc-123'}, + 'keys': [ + {'key': 'LATITUDE', 'label': '', 'valueType': 'ATTRIBUTE'}, + ], + }), + throwsFormatException, + ); + }); + test('toJson round-trips through fromJson', () { const original = LiveTrackingConfig( target: LiveTrackingTarget(entityType: 'DEVICE', id: 'abc-123'), - latitudeKey: 'lat', - longitudeKey: 'lng', - includeMetadata: true, - mirrorToAttributes: true, + keys: [ + LiveTrackingKey( + key: LiveTrackingKeyType.latitude, + label: 'lat', + valueType: LiveTrackingValueType.attribute, + ), + LiveTrackingKey( + key: LiveTrackingKeyType.heading, + label: 'gpsHeading', + valueType: LiveTrackingValueType.timeseries, + ), + ], accuracy: LocationAccuracyLevel.high, distanceFilterMeters: 25, intervalSeconds: 60, - maxDurationMinutes: 120, - writeStatusAttributes: false, + maxDurationSeconds: 7200, trackedBy: 'user@example.com', ); @@ -93,15 +149,15 @@ void main() { expect(restored.target.entityType, 'DEVICE'); expect(restored.target.id, 'abc-123'); - expect(restored.latitudeKey, 'lat'); - expect(restored.longitudeKey, 'lng'); - expect(restored.includeMetadata, true); - expect(restored.mirrorToAttributes, true); + expect(restored.keys.length, 2); + expect(restored.keys.first.label, 'lat'); + expect(restored.keys.first.valueType, LiveTrackingValueType.attribute); + expect(restored.keys.last.key, LiveTrackingKeyType.heading); + expect(restored.keys.last.valueType, LiveTrackingValueType.timeseries); expect(restored.accuracy, LocationAccuracyLevel.high); expect(restored.distanceFilterMeters, 25); expect(restored.intervalSeconds, 60); - expect(restored.maxDurationMinutes, 120); - expect(restored.writeStatusAttributes, false); + expect(restored.maxDurationSeconds, 7200); expect(restored.trackedBy, 'user@example.com'); }); } diff --git a/test/utils/services/live_location_tracking/live_tracking_store_test.dart b/test/utils/services/live_location_tracking/live_tracking_store_test.dart index c7cf4b80..9d54e4c3 100644 --- a/test/utils/services/live_location_tracking/live_tracking_store_test.dart +++ b/test/utils/services/live_location_tracking/live_tracking_store_test.dart @@ -33,6 +33,9 @@ void main() { final record = LastTrackingRecord( configJson: const { 'target': {'entityType': 'DEVICE', 'id': 'd-1'}, + 'keys': [ + {'key': 'LATITUDE', 'label': 'latitude', 'valueType': 'ATTRIBUTE'}, + ], }, startedAt: DateTime.fromMillisecondsSinceEpoch(1720000000000), endReason: TrackingEndReason.interrupted, diff --git a/test/utils/services/mobile_actions/live_location_actions_test.dart b/test/utils/services/mobile_actions/live_location_actions_test.dart index accfd82a..33f1f2e4 100644 --- a/test/utils/services/mobile_actions/live_location_actions_test.dart +++ b/test/utils/services/mobile_actions/live_location_actions_test.dart @@ -67,6 +67,9 @@ void main() { 'startLiveLocation', { 'target': {'entityType': 'DEVICE', 'id': 'd-1'}, + 'keys': [ + {'key': 'LATITUDE', 'label': 'latitude', 'valueType': 'ATTRIBUTE'}, + ], 'trackedBy': 'me@tb.io', }, ], FakeController()); @@ -91,7 +94,7 @@ void main() { test('start action with malformed target returns an error result', () async { final result = await StartLiveLocationAction().execute([ 'startLiveLocation', - {'latitudeKey': 'lat'}, + {'keys': []}, ], FakeController()); expect(result.toJson()['hasError'], true); @@ -101,6 +104,13 @@ void main() { tracking.session = LiveTrackingSession( config: const LiveTrackingConfig( target: LiveTrackingTarget(entityType: 'DEVICE', id: 'd-1'), + keys: [ + LiveTrackingKey( + key: LiveTrackingKeyType.latitude, + label: 'latitude', + valueType: LiveTrackingValueType.attribute, + ), + ], ), status: LiveTrackingStatus.tracking, startedAt: DateTime.fromMillisecondsSinceEpoch(0), From 4b66c8790cabfa493fda9fa5a7f5830327fcb701 Mon Sep 17 00:00:00 2001 From: ababak Date: Tue, 28 Jul 2026 19:03:57 +0300 Subject: [PATCH 32/40] feat(location): session details with linkable target and source dashboard - carry the web-resolved target name and source dashboard in the tracking wire config and persist them with the last-session record - return trackingInfo (target name, saved keys) from the stop action ack so the dashboard dialog can show what was tracked - tracking page: target entity links to its details (device profile dashboard, asset or customer details), source dashboard opens on tap - tracking page: "Save location to entity" label, play/pause status icon with green/amber color, start/end times without milliseconds --- lib/generated/intl/messages_en.dart | 9 +- lib/generated/l10n.dart | 27 ++- lib/l10n/intl_en.arb | 7 +- .../presentation/view/live_tracking_page.dart | 188 ++++++++++++++++-- .../live_location_tracking_service.dart | 1 + .../model/live_tracking_config.dart | 34 ++++ .../actions/stop_live_location_action.dart | 17 +- .../mobile_actions/mobile_action_result.dart | 7 +- .../mobile_actions/results/launch_result.dart | 11 +- .../live_tracking_config_test.dart | 24 +++ .../live_location_actions_test.dart | 65 +++++- 11 files changed, 355 insertions(+), 35 deletions(-) diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index 6083465c..a1e5d879 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -481,6 +481,7 @@ class MessageLookup extends MessageLookupByLibrary { "liveTrackingActive": MessageLookupByLibrary.simpleMessage( "Live location tracking", ), + "liveTrackingDashboard": MessageLookupByLibrary.simpleMessage("Dashboard"), "liveTrackingEndReason": MessageLookupByLibrary.simpleMessage("End reason"), "liveTrackingEndReasonInterrupted": MessageLookupByLibrary.simpleMessage( "Interrupted", @@ -491,7 +492,7 @@ class MessageLookup extends MessageLookupByLibrary { "liveTrackingEndReasonMaxDuration": MessageLookupByLibrary.simpleMessage( "Reached max duration", ), - "liveTrackingEnded": MessageLookupByLibrary.simpleMessage("Ended"), + "liveTrackingEnded": MessageLookupByLibrary.simpleMessage("End time"), "liveTrackingErrors": MessageLookupByLibrary.simpleMessage("Errors"), "liveTrackingFixes": MessageLookupByLibrary.simpleMessage("Fixes"), "liveTrackingHide": MessageLookupByLibrary.simpleMessage("Hide"), @@ -518,10 +519,12 @@ class MessageLookup extends MessageLookupByLibrary { "liveTrackingStartAgain": MessageLookupByLibrary.simpleMessage( "Start again", ), - "liveTrackingStarted": MessageLookupByLibrary.simpleMessage("Started"), + "liveTrackingStarted": MessageLookupByLibrary.simpleMessage("Start time"), "liveTrackingStatus": MessageLookupByLibrary.simpleMessage("Status"), "liveTrackingStop": MessageLookupByLibrary.simpleMessage("Stop"), - "liveTrackingTarget": MessageLookupByLibrary.simpleMessage("Target entity"), + "liveTrackingTarget": MessageLookupByLibrary.simpleMessage( + "Save location to entity", + ), "login": MessageLookupByLibrary.simpleMessage("Log In"), "loginToApp": MessageLookupByLibrary.simpleMessage("Login to app"), "loginToYourAccount": MessageLookupByLibrary.simpleMessage( diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 427213eb..0099a779 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -3389,16 +3389,26 @@ class S { ); } - /// `Target entity` + /// `Save location to entity` String get liveTrackingTarget { return Intl.message( - 'Target entity', + 'Save location to entity', name: 'liveTrackingTarget', desc: '', args: [], ); } + /// `Dashboard` + String get liveTrackingDashboard { + return Intl.message( + 'Dashboard', + name: 'liveTrackingDashboard', + desc: '', + args: [], + ); + } + /// `Status` String get liveTrackingStatus { return Intl.message( @@ -3409,10 +3419,10 @@ class S { ); } - /// `Started` + /// `Start time` String get liveTrackingStarted { return Intl.message( - 'Started', + 'Start time', name: 'liveTrackingStarted', desc: '', args: [], @@ -3479,9 +3489,14 @@ class S { ); } - /// `Ended` + /// `End time` String get liveTrackingEnded { - return Intl.message('Ended', name: 'liveTrackingEnded', desc: '', args: []); + return Intl.message( + 'End time', + name: 'liveTrackingEnded', + desc: '', + args: [], + ); } /// `End reason` diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 6779715a..854b3f1d 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -481,16 +481,17 @@ "liveTrackingResume": "Resume", "liveTrackingHide": "Hide", "liveTrackingSessionTitle": "Live location tracking", - "liveTrackingTarget": "Target entity", + "liveTrackingTarget": "Save location to entity", + "liveTrackingDashboard": "Dashboard", "liveTrackingStatus": "Status", - "liveTrackingStarted": "Started", + "liveTrackingStarted": "Start time", "liveTrackingLastFix": "Last fix", "liveTrackingLastError": "Last error", "liveTrackingMenuTitle": "Live location tracking", "liveTrackingNoRecord": "No active tracking and no recent session.", "liveTrackingLastSession": "Last session", "liveTrackingStartAgain": "Start again", - "liveTrackingEnded": "Ended", + "liveTrackingEnded": "End time", "liveTrackingEndReason": "End reason", "liveTrackingEndReasonManual": "Stopped manually", "liveTrackingEndReasonMaxDuration": "Reached max duration", diff --git a/lib/modules/location_tracking/presentation/view/live_tracking_page.dart b/lib/modules/location_tracking/presentation/view/live_tracking_page.dart index 6a49be59..31ea155c 100644 --- a/lib/modules/location_tracking/presentation/view/live_tracking_page.dart +++ b/lib/modules/location_tracking/presentation/view/live_tracking_page.dart @@ -1,13 +1,24 @@ import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:thingsboard_app/config/routes/v2/routes_config/routes/asset_routes.dart'; +import 'package:thingsboard_app/config/routes/v2/routes_config/routes/customer_routes.dart'; +import 'package:thingsboard_app/config/routes/v2/routes_config/routes/dashboard_routes.dart'; +import 'package:thingsboard_app/config/themes/app_colors.dart'; +import 'package:thingsboard_app/core/logger/tb_logger.dart'; import 'package:thingsboard_app/generated/l10n.dart'; import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/modules/dashboard/domain/entites/dashboard_arguments.dart'; import 'package:thingsboard_app/modules/location_tracking/presentation/provider/live_tracking_provider.dart'; +import 'package:thingsboard_app/utils/services/device_profile/device_profile_cache.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_display.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; +import 'package:thingsboard_app/utils/services/overlay_service/i_overlay_service.dart'; import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; +import 'package:thingsboard_app/utils/utils.dart'; class LiveTrackingPage extends ConsumerWidget { const LiveTrackingPage({super.key}); @@ -22,6 +33,127 @@ class LiveTrackingPage extends ConsumerWidget { } } +final _sessionTimeFormat = DateFormat('yyyy-MM-dd HH:mm:ss'); + +String _formatSessionTime(DateTime time) => + _sessionTimeFormat.format(time.toLocal()); + +TextStyle _linkStyle(BuildContext context) { + final color = Theme.of(context).colorScheme.primary; + return TextStyle( + color: color, + decoration: TextDecoration.underline, + decorationColor: color, + ); +} + +bool _targetHasDetailsPage(String entityType) => switch (entityType) { + 'DEVICE' || 'ASSET' || 'CUSTOMER' => true, + _ => false, +}; + +Future _openTargetEntity( + BuildContext context, + LiveTrackingTarget target, +) async { + switch (target.entityType) { + case 'ASSET': + context.push('${AssetRoutes.asset}/${target.id}'); + case 'CUSTOMER': + context.push( + '${CustomerRoutes.customers}${CustomerRoutes.customer}/${target.id}', + ); + case 'DEVICE': + await _openDeviceTarget(context, target.id); + } +} + +/// Mirrors the devices-list tap behavior: opens the dashboard configured in +/// the device profile, or warns a tenant admin that none is configured. +Future _openDeviceTarget(BuildContext context, String deviceId) async { + final tbClient = getIt().client; + try { + final device = + (await tbClient.getDeviceControllerApi().getDeviceById( + deviceId: deviceId, + )).data; + if (device == null) { + return; + } + final profile = await DeviceProfileCache.getDeviceProfileInfo( + tbClient, + device.type ?? '', + deviceId, + ); + final dashboardId = profile.info.defaultDashboardId?.id; + if (dashboardId == null) { + if (tbClient.isTenantAdmin()) { + getIt().showWarnNotification( + (context) => + S.of(context).mobileDashboardShouldBeConfiguredInDeviceProfile, + ); + } + return; + } + final state = Utils.createDashboardEntityState( + device.id, + entityName: device.name, + entityLabel: device.label, + ); + if (context.mounted) { + context.push( + DashboardRoutes.dashboard, + extra: DashboardArgumentsEntity( + id: dashboardId, + title: device.name, + state: state, + hideToolbar: false, + animate: false, + ), + ); + } + } catch (e, s) { + getIt().error('LiveTrackingPage: failed to open device', e, s); + } +} + +/// Tiles describing where fixes are saved: target entity (a link when its +/// type has a details page) and the dashboard the session was started from +/// (a link when its id is known). +List _saveConfigTiles( + BuildContext context, + LiveTrackingConfig config, + String targetName, +) { + final target = config.target; + final targetLinkable = _targetHasDetailsPage(target.entityType); + final dashboard = config.dashboard; + final dashboardId = dashboard?.id; + return [ + ListTile( + title: Text(S.of(context).liveTrackingTarget), + subtitle: Text( + targetName, + style: targetLinkable ? _linkStyle(context) : null, + ), + onTap: targetLinkable ? () => _openTargetEntity(context, target) : null, + ), + if (dashboard != null) + ListTile( + title: Text(S.of(context).liveTrackingDashboard), + subtitle: Text( + dashboard.title ?? dashboardId ?? '', + style: dashboardId != null ? _linkStyle(context) : null, + ), + onTap: + dashboardId != null + ? () => + context.push('${DashboardRoutes.dashboard}/$dashboardId') + : null, + ), + ]; +} + class _ActiveSession extends ConsumerWidget { const _ActiveSession({required this.session}); @@ -35,24 +167,42 @@ class _ActiveSession extends ConsumerWidget { final nameAsync = ref.watch( targetNameProvider(entityType: target.entityType, id: target.id), ); - final name = displayTargetName(nameAsync.valueOrNull, target); + final name = displayTargetName( + nameAsync.valueOrNull ?? session.config.targetName, + target, + ); + final statusColor = + tracking + ? AppColors.notificationSuccess + : AppColors.notificationWarning; return ListView( children: [ - ListTile( - title: Text(S.of(context).liveTrackingTarget), - subtitle: Text(name), - ), + ..._saveConfigTiles(context, session.config, name), ListTile( title: Text(S.of(context).liveTrackingStatus), - subtitle: Text( - tracking - ? S.of(context).liveTrackingActive - : S.of(context).liveTrackingPaused, + subtitle: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + tracking ? Icons.play_arrow : Icons.pause, + size: 18, + color: statusColor, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + tracking + ? S.of(context).liveTrackingActive + : S.of(context).liveTrackingPaused, + style: TextStyle(color: statusColor), + ), + ), + ], ), ), ListTile( title: Text(S.of(context).liveTrackingStarted), - subtitle: Text(session.startedAt.toLocal().toString()), + subtitle: Text(_formatSessionTime(session.startedAt)), ), ListTile( title: Text( @@ -156,22 +306,28 @@ class _LastSession extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final target = record.config.target; - final name = displayTargetName(record.targetName, target); + final config = record.config; + final name = displayTargetName( + record.targetName ?? config.targetName, + config.target, + ); return ListView( children: [ ListTile( - title: Text(S.of(context).liveTrackingLastSession), - subtitle: Text(name), + title: Text( + S.of(context).liveTrackingLastSession, + style: Theme.of(context).textTheme.titleMedium, + ), ), + ..._saveConfigTiles(context, config, name), ListTile( title: Text(S.of(context).liveTrackingStarted), - subtitle: Text(record.startedAt.toLocal().toString()), + subtitle: Text(_formatSessionTime(record.startedAt)), ), if (record.endedAt != null) ListTile( title: Text(S.of(context).liveTrackingEnded), - subtitle: Text(record.endedAt!.toLocal().toString()), + subtitle: Text(_formatSessionTime(record.endedAt!)), ), ListTile( title: Text(S.of(context).liveTrackingEndReason), diff --git a/lib/utils/services/live_location_tracking/live_location_tracking_service.dart b/lib/utils/services/live_location_tracking/live_location_tracking_service.dart index 76c571e8..9f669672 100644 --- a/lib/utils/services/live_location_tracking/live_location_tracking_service.dart +++ b/lib/utils/services/live_location_tracking/live_location_tracking_service.dart @@ -69,6 +69,7 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { await _store.write( LastTrackingRecord( configJson: config.toJson(), + targetName: config.targetName, startedAt: startedAt, endReason: TrackingEndReason.interrupted, ), diff --git a/lib/utils/services/live_location_tracking/model/live_tracking_config.dart b/lib/utils/services/live_location_tracking/model/live_tracking_config.dart index 6b2fafbc..650c4c2a 100644 --- a/lib/utils/services/live_location_tracking/model/live_tracking_config.dart +++ b/lib/utils/services/live_location_tracking/model/live_tracking_config.dart @@ -97,6 +97,25 @@ class LiveTrackingKey { }; } +/// The dashboard a tracking session was started from, for display and +/// navigation back to it. Both fields are optional on the wire. +class LiveTrackingDashboard { + const LiveTrackingDashboard({this.id, this.title}); + + factory LiveTrackingDashboard.fromJson(Map json) => + LiveTrackingDashboard( + id: json['id'] as String?, + title: json['title'] as String?, + ); + + final String? id; + final String? title; + + bool get isEmpty => id == null && title == null; + + Map toJson() => {'id': id, 'title': title}; +} + /// Fully-resolved live tracking session config received from the dashboard /// (the web side resolves aliases/attributes to a concrete target entity and /// key labels to their effective names before sending). @@ -104,6 +123,8 @@ class LiveTrackingConfig { const LiveTrackingConfig({ required this.target, required this.keys, + this.targetName, + this.dashboard, this.accuracy = LocationAccuracyLevel.balanced, this.distanceFilterMeters, this.intervalSeconds, @@ -120,6 +141,13 @@ class LiveTrackingConfig { if (keysJson is! List || keysJson.isEmpty) { throw const FormatException('Live tracking config is missing keys'); } + final dashboardJson = json['dashboard']; + final dashboard = + dashboardJson is Map + ? LiveTrackingDashboard.fromJson( + Map.from(dashboardJson), + ) + : null; return LiveTrackingConfig( target: LiveTrackingTarget.fromJson( Map.from(targetJson), @@ -130,6 +158,8 @@ class LiveTrackingConfig { LiveTrackingKey.fromJson(Map.from(key as Map)), ) .toList(growable: false), + targetName: json['targetName'] as String?, + dashboard: dashboard == null || dashboard.isEmpty ? null : dashboard, accuracy: _accuracyFromString(json['accuracy'] as String?), distanceFilterMeters: (json['distanceFilterMeters'] as num?)?.toInt(), intervalSeconds: (json['intervalSeconds'] as num?)?.toInt(), @@ -140,6 +170,8 @@ class LiveTrackingConfig { final LiveTrackingTarget target; final List keys; + final String? targetName; + final LiveTrackingDashboard? dashboard; final LocationAccuracyLevel accuracy; final int? distanceFilterMeters; final int? intervalSeconds; @@ -156,6 +188,8 @@ class LiveTrackingConfig { Map toJson() => { 'target': target.toJson(), 'keys': keys.map((key) => key.toJson()).toList(growable: false), + 'targetName': targetName, + 'dashboard': dashboard?.toJson(), 'accuracy': _accuracyToString(accuracy), 'distanceFilterMeters': distanceFilterMeters, 'intervalSeconds': intervalSeconds, diff --git a/lib/utils/services/mobile_actions/actions/stop_live_location_action.dart b/lib/utils/services/mobile_actions/actions/stop_live_location_action.dart index b6855114..8760fdb0 100644 --- a/lib/utils/services/mobile_actions/actions/stop_live_location_action.dart +++ b/lib/utils/services/mobile_actions/actions/stop_live_location_action.dart @@ -1,6 +1,8 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:thingsboard_app/locator.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_display.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action_result.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_result.dart'; @@ -14,12 +16,23 @@ class StopLiveLocationAction extends MobileAction { ) async { try { final service = getIt(); - if (service.session == null) { + final session = service.session; + if (session == null) { return WidgetMobileActionResult.emptyResult(); } + final config = session.config; + final resolvedName = + config.targetName ?? + (await getIt().read())?.targetName; await service.stop(); return WidgetMobileActionResult.successResult( - MobileActionResult.launched(true), + MobileActionResult.launched( + true, + trackingInfo: { + 'targetName': displayTargetName(resolvedName, config.target), + 'keys': config.keys.map((key) => key.label).toList(growable: false), + }, + ), ); } catch (e) { return handleError(e); diff --git a/lib/utils/services/mobile_actions/mobile_action_result.dart b/lib/utils/services/mobile_actions/mobile_action_result.dart index eeffaf57..153d4d84 100644 --- a/lib/utils/services/mobile_actions/mobile_action_result.dart +++ b/lib/utils/services/mobile_actions/mobile_action_result.dart @@ -7,8 +7,11 @@ import 'package:thingsboard_app/utils/services/mobile_actions/results/qr_code_re abstract class MobileActionResult { MobileActionResult(); - factory MobileActionResult.launched(bool launched) { - return LaunchResult(launched); + factory MobileActionResult.launched( + bool launched, { + Map? trackingInfo, + }) { + return LaunchResult(launched, trackingInfo: trackingInfo); } factory MobileActionResult.image(String imageUrl) { diff --git a/lib/utils/services/mobile_actions/results/launch_result.dart b/lib/utils/services/mobile_actions/results/launch_result.dart index af3fb4c6..0a097aa8 100644 --- a/lib/utils/services/mobile_actions/results/launch_result.dart +++ b/lib/utils/services/mobile_actions/results/launch_result.dart @@ -1,14 +1,21 @@ import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action_result.dart'; class LaunchResult extends MobileActionResult { - - LaunchResult(this.launched); + LaunchResult(this.launched, {this.trackingInfo}); bool launched; + /// Live tracking session details ({targetName, keys}) attached to + /// start/stop live location acks so the dashboard can show what is being + /// saved and where. + Map? trackingInfo; + @override Map toJson() { final json = super.toJson(); json['launched'] = launched; + if (trackingInfo != null) { + json['trackingInfo'] = trackingInfo; + } return json; } } diff --git a/test/utils/services/live_location_tracking/live_tracking_config_test.dart b/test/utils/services/live_location_tracking/live_tracking_config_test.dart index 781d5509..a39e1705 100644 --- a/test/utils/services/live_location_tracking/live_tracking_config_test.dart +++ b/test/utils/services/live_location_tracking/live_tracking_config_test.dart @@ -11,6 +11,8 @@ void main() { {'key': 'LONGITUDE', 'label': 'lng', 'valueType': 'ATTRIBUTE'}, {'key': 'SPEED', 'label': 'gpsSpeed', 'valueType': 'TIMESERIES'}, ], + 'targetName': 'Test Device B1', + 'dashboard': {'id': 'dash-1', 'title': 'GPS tracker'}, 'accuracy': 'HIGH', 'distanceFilterMeters': 25, 'intervalSeconds': 60, @@ -20,6 +22,9 @@ void main() { expect(config.target.entityType, 'DEVICE'); expect(config.target.id, 'abc-123'); + expect(config.targetName, 'Test Device B1'); + expect(config.dashboard?.id, 'dash-1'); + expect(config.dashboard?.title, 'GPS tracker'); expect(config.keys.length, 3); expect(config.keys.first.key, LiveTrackingKeyType.latitude); expect(config.keys.first.label, 'lat'); @@ -46,6 +51,20 @@ void main() { expect(config.intervalSeconds, isNull); expect(config.maxDurationSeconds, isNull); expect(config.trackedBy, isNull); + expect(config.targetName, isNull); + expect(config.dashboard, isNull); + }); + + test('dashboard with only null fields parses as null', () { + final config = LiveTrackingConfig.fromJson(const { + 'target': {'entityType': 'USER', 'id': 'u-1'}, + 'keys': [ + {'key': 'LATITUDE', 'label': 'latitude', 'valueType': 'ATTRIBUTE'}, + ], + 'dashboard': {'id': null, 'title': null}, + }); + + expect(config.dashboard, isNull); }); test('unknown accuracy falls back to balanced', () { @@ -138,6 +157,8 @@ void main() { valueType: LiveTrackingValueType.timeseries, ), ], + targetName: 'Test Device B1', + dashboard: LiveTrackingDashboard(id: 'dash-1', title: 'GPS tracker'), accuracy: LocationAccuracyLevel.high, distanceFilterMeters: 25, intervalSeconds: 60, @@ -154,6 +175,9 @@ void main() { expect(restored.keys.first.valueType, LiveTrackingValueType.attribute); expect(restored.keys.last.key, LiveTrackingKeyType.heading); expect(restored.keys.last.valueType, LiveTrackingValueType.timeseries); + expect(restored.targetName, 'Test Device B1'); + expect(restored.dashboard?.id, 'dash-1'); + expect(restored.dashboard?.title, 'GPS tracker'); expect(restored.accuracy, LocationAccuracyLevel.high); expect(restored.distanceFilterMeters, 25); expect(restored.intervalSeconds, 60); diff --git a/test/utils/services/mobile_actions/live_location_actions_test.dart b/test/utils/services/mobile_actions/live_location_actions_test.dart index 33f1f2e4..611d4f8e 100644 --- a/test/utils/services/mobile_actions/live_location_actions_test.dart +++ b/test/utils/services/mobile_actions/live_location_actions_test.dart @@ -2,6 +2,8 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:get_it/get_it.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/start_live_location_action.dart'; @@ -10,6 +12,23 @@ import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_acti class FakeController extends Fake implements InAppWebViewController {} +class FakeStore implements ILiveTrackingStore { + LastTrackingRecord? record; + + @override + Future read() async => record; + + @override + Future write(LastTrackingRecord record) async { + this.record = record; + } + + @override + Future clear() async { + record = null; + } +} + class FakeTrackingService implements ILiveLocationTrackingService { LiveTrackingConfig? startedWith; bool stopped = false; @@ -39,10 +58,13 @@ class FakeTrackingService implements ILiveLocationTrackingService { void main() { late FakeTrackingService tracking; + late FakeStore store; setUp(() { tracking = FakeTrackingService(); + store = FakeStore(); GetIt.I.registerLazySingleton(() => tracking); + GetIt.I.registerLazySingleton(() => store); }); tearDown(() async { @@ -104,6 +126,7 @@ void main() { tracking.session = LiveTrackingSession( config: const LiveTrackingConfig( target: LiveTrackingTarget(entityType: 'DEVICE', id: 'd-1'), + targetName: 'Test Device B1', keys: [ LiveTrackingKey( key: LiveTrackingKeyType.latitude, @@ -121,9 +144,49 @@ void main() { ], FakeController()); expect(tracking.stopped, true); - expect(result.toJson()['hasResult'], true); + final json = result.toJson(); + expect(json['hasResult'], true); + final resultJson = json['result'] as Map; + expect(resultJson['launched'], true); + final trackingInfo = resultJson['trackingInfo'] as Map; + expect(trackingInfo['targetName'], 'Test Device B1'); + expect(trackingInfo['keys'], ['latitude']); }); + test( + 'stop action falls back to the stored name when config has none', + () async { + tracking.session = LiveTrackingSession( + config: const LiveTrackingConfig( + target: LiveTrackingTarget(entityType: 'DEVICE', id: 'd-1'), + keys: [ + LiveTrackingKey( + key: LiveTrackingKeyType.latitude, + label: 'latitude', + valueType: LiveTrackingValueType.attribute, + ), + ], + ), + status: LiveTrackingStatus.tracking, + startedAt: DateTime.fromMillisecondsSinceEpoch(0), + ); + store.record = LastTrackingRecord( + configJson: tracking.session!.config.toJson(), + targetName: 'Resolved Name', + startedAt: DateTime.fromMillisecondsSinceEpoch(0), + endReason: TrackingEndReason.interrupted, + ); + + final result = await StopLiveLocationAction().execute([ + 'stopLiveLocation', + ], FakeController()); + + final resultJson = result.toJson()['result'] as Map; + final trackingInfo = resultJson['trackingInfo'] as Map; + expect(trackingInfo['targetName'], 'Resolved Name'); + }, + ); + test('stop action with no session returns empty result', () async { final result = await StopLiveLocationAction().execute([ 'stopLiveLocation', From 06ab3777ed1dc83fac24d3a6b1c5eb6eaaf9dff3 Mon Sep 17 00:00:00 2001 From: ababak Date: Thu, 30 Jul 2026 14:54:13 +0300 Subject: [PATCH 33/40] fix(location): stop live tracking and clear session record on logout The teardown added in d80b3ba lived only in the legacy TbContext.logout() which no user-facing path calls, so logging out left GPS streaming, kept posting telemetry with a dead token, and resumed the session on the next login. LoginProvider.logout() now stops tracking and clears the record before tbClient.logout(), while the token needed for the gpsActive=false write is still valid. Covers every logout entry point. --- lib/core/auth/login/provider/login_provider.dart | 11 +++++++++++ lib/core/auth/login/provider/login_provider.g.dart | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/lib/core/auth/login/provider/login_provider.dart b/lib/core/auth/login/provider/login_provider.dart index f63e48ee..ef97041d 100644 --- a/lib/core/auth/login/provider/login_provider.dart +++ b/lib/core/auth/login/provider/login_provider.dart @@ -14,6 +14,8 @@ import 'package:thingsboard_app/utils/services/communication/events/user_loaded_ import 'package:thingsboard_app/utils/services/communication/i_communication_service.dart'; import 'package:thingsboard_app/utils/services/device_info/i_device_info_service.dart'; import 'package:thingsboard_app/utils/services/firebase/i_firebase_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; import 'package:thingsboard_app/utils/services/notification_service.dart'; import 'package:thingsboard_app/utils/services/overlay_service/i_overlay_service.dart'; import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; @@ -40,6 +42,15 @@ class Login extends _$Login { } Future logout() async { + // Tracking teardown writes gpsActive=false to the target entity, which + // needs the still-valid token — it must complete before the client logout + // invalidates it. A teardown failure must not keep the user logged in. + try { + await getIt().stop(); + await getIt().clear(); + } catch (e) { + log('logout: live tracking teardown failed: $e'); + } if (getIt().apps.isNotEmpty && state.isFullyAuthenticated()) { await getIt().logout(); diff --git a/lib/core/auth/login/provider/login_provider.g.dart b/lib/core/auth/login/provider/login_provider.g.dart index 6597a90f..51448841 100644 --- a/lib/core/auth/login/provider/login_provider.g.dart +++ b/lib/core/auth/login/provider/login_provider.g.dart @@ -6,7 +6,7 @@ part of 'login_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$loginHash() => r'6aea9a4e0e56ecd174fecf5836f1bfdf29c9a713'; +String _$loginHash() => r'154e055ae65de0266094443b5a2fc7af886eab62'; /// See also [Login]. @ProviderFor(Login) From 34eb12ae05e15bb869c132bc47c9fc9074e0f64f Mon Sep 17 00:00:00 2001 From: ababak Date: Thu, 30 Jul 2026 14:54:20 +0300 Subject: [PATCH 34/40] fix(location): run live tracking saves as background requests Periodic save failures raised the global modal "Connection error" dialog over whatever the user was doing (once per failed save) and toggled the global loading state. Telemetry/attribute saves and the target-name query now carry ignoreErrors+ignoreLoading, so the session screen's error counter and last-error line remain the only surface. --- lib/constants/app_constants.dart | 12 +++++++++--- .../live_location_tracking/entity_name_resolver.dart | 6 +++++- .../live_location_tracking/live_tracking_remote.dart | 3 +++ 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/lib/constants/app_constants.dart b/lib/constants/app_constants.dart index 84f4b510..f4ba956c 100644 --- a/lib/constants/app_constants.dart +++ b/lib/constants/app_constants.dart @@ -18,8 +18,14 @@ abstract final class ThingsboardAppConstants { /// Dio `extra` flag telling the TB client to skip the global error overlay so /// the caller can handle the failure (e.g. a 429) inline. static const ignoreErrors = {'ignoreErrors': true}; - static final navigationType = - TbNavigationType.fromString( - const String.fromEnvironment('navigationType'), + + /// Dio `extra` flags for requests that run behind the user's back (periodic + /// live-tracking saves): no global error dialog, no loading indicator. + static const backgroundRequest = { + 'ignoreErrors': true, + 'ignoreLoading': true, + }; + static final navigationType = TbNavigationType.fromString( + const String.fromEnvironment('navigationType'), ); } diff --git a/lib/utils/services/live_location_tracking/entity_name_resolver.dart b/lib/utils/services/live_location_tracking/entity_name_resolver.dart index b5158228..5851a453 100644 --- a/lib/utils/services/live_location_tracking/entity_name_resolver.dart +++ b/lib/utils/services/live_location_tracking/entity_name_resolver.dart @@ -1,3 +1,4 @@ +import 'package:thingsboard_app/constants/app_constants.dart'; import 'package:thingsboard_app/core/logger/tb_logger.dart'; import 'package:thingsboard_app/utils/services/entity_query_api.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; @@ -19,7 +20,10 @@ class EntityNameResolver implements IEntityNameResolver { final query = EntityQueryApi.createEntityNameQuery(entityType, id); final response = await _clientService.client .getEntityQueryControllerApi() - .findEntityDataByQuery(entityDataQuery: query); + .findEntityDataByQuery( + entityDataQuery: query, + extra: ThingsboardAppConstants.backgroundRequest, + ); final data = response.data?.data; if (data == null || data.isEmpty) { return null; diff --git a/lib/utils/services/live_location_tracking/live_tracking_remote.dart b/lib/utils/services/live_location_tracking/live_tracking_remote.dart index 23dad5f1..ef8c42a6 100644 --- a/lib/utils/services/live_location_tracking/live_tracking_remote.dart +++ b/lib/utils/services/live_location_tracking/live_tracking_remote.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:thingsboard_app/constants/app_constants.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_remote.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; @@ -21,6 +22,7 @@ class LiveTrackingRemote implements ILiveTrackingRemote { entityId: target.id, scope: 'ANY', body: jsonEncode({'ts': ts, 'values': values}), + extra: ThingsboardAppConstants.backgroundRequest, ); } @@ -36,6 +38,7 @@ class LiveTrackingRemote implements ILiveTrackingRemote { entityId: target.id, scope: 'SERVER_SCOPE', body: jsonEncode(attributes), + extra: ThingsboardAppConstants.backgroundRequest, ); } } From f2e90500ef5fe39dc662cd584be3dc513a4bf646 Mon Sep 17 00:00:00 2001 From: ababak Date: Thu, 30 Jul 2026 14:54:31 +0300 Subject: [PATCH 35/40] fix(location): localized session errors, visible pause when location is lost - Store lastError as a LiveTrackingError cause instead of a raw exception string; the session screen mapped a Dio/ThingsboardError toString() with ten stack frames onto the UI. Causes are classified by status/error code (the 404 arrives with an empty message) and localized in the UI layer; full exception + trace keep going to the logger only. - Clear lastError on a successful fix so a resolved error does not stay on screen forever. - Map geolocator stream errors to the sealed LocationFix cases: turning device location off mid-session previously landed as a generic fix error and the session kept looking healthy. It now pauses visibly and writes gpsActive=false. - Show a system notification while a session is paused, cleared on resume/stop and at app start (a leftover notification must not outlive the process the session died with). --- lib/generated/intl/messages_en.dart | 25 +++++ lib/generated/l10n.dart | 80 ++++++++++++++++ lib/l10n/intl_en.arb | 8 ++ lib/locator.dart | 6 ++ lib/main.dart | 7 +- .../presentation/view/live_tracking_page.dart | 21 ++++- .../i_live_tracking_notifications.dart | 9 ++ .../live_location_tracking_service.dart | 41 +++++--- .../live_tracking_notifications.dart | 83 ++++++++++++++++ .../model/live_tracking_error.dart | 39 ++++++++ .../model/live_tracking_session.dart | 3 +- .../model/live_tracking_session.freezed.dart | 18 ++-- .../services/location/location_service.dart | 14 ++- .../live_location_tracking_service_test.dart | 94 ++++++++++++++++++- .../live_tracking_error_test.dart | 91 ++++++++++++++++++ .../location/location_service_test.dart | 77 +++++++++++++++ 16 files changed, 590 insertions(+), 26 deletions(-) create mode 100644 lib/utils/services/live_location_tracking/i_live_tracking_notifications.dart create mode 100644 lib/utils/services/live_location_tracking/live_tracking_notifications.dart create mode 100644 lib/utils/services/live_location_tracking/model/live_tracking_error.dart create mode 100644 test/utils/services/live_location_tracking/live_tracking_error_test.dart create mode 100644 test/utils/services/location/location_service_test.dart diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index a1e5d879..f8d134fc 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -493,6 +493,31 @@ class MessageLookup extends MessageLookupByLibrary { "Reached max duration", ), "liveTrackingEnded": MessageLookupByLibrary.simpleMessage("End time"), + "liveTrackingErrorLocation": MessageLookupByLibrary.simpleMessage( + "Couldn\'t get a location fix.", + ), + "liveTrackingErrorNoConnection": MessageLookupByLibrary.simpleMessage( + "No connection to the server. These fixes weren\'t saved — saving resumes when you\'re back online.", + ), + "liveTrackingErrorPermissionDenied": MessageLookupByLibrary.simpleMessage( + "Location permission was denied.", + ), + "liveTrackingErrorPermissionDeniedForever": + MessageLookupByLibrary.simpleMessage( + "Location permission is permanently denied. Enable it in the app settings.", + ), + "liveTrackingErrorSaveFailed": MessageLookupByLibrary.simpleMessage( + "Couldn\'t save this fix to the server. Saving retries with the next fix.", + ), + "liveTrackingErrorServicesDisabled": MessageLookupByLibrary.simpleMessage( + "Location services are turned off on this device.", + ), + "liveTrackingErrorTargetNotFound": MessageLookupByLibrary.simpleMessage( + "Can\'t save to this entity — it no longer exists. Tracking continues; update the target in the dashboard action.", + ), + "liveTrackingErrorUnauthorized": MessageLookupByLibrary.simpleMessage( + "Your session ended. Sign in again to keep saving location.", + ), "liveTrackingErrors": MessageLookupByLibrary.simpleMessage("Errors"), "liveTrackingFixes": MessageLookupByLibrary.simpleMessage("Fixes"), "liveTrackingHide": MessageLookupByLibrary.simpleMessage("Hide"), diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 0099a779..8c6817a9 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -3449,6 +3449,86 @@ class S { ); } + /// `Can't save to this entity — it no longer exists. Tracking continues; update the target in the dashboard action.` + String get liveTrackingErrorTargetNotFound { + return Intl.message( + 'Can\'t save to this entity — it no longer exists. Tracking continues; update the target in the dashboard action.', + name: 'liveTrackingErrorTargetNotFound', + desc: '', + args: [], + ); + } + + /// `No connection to the server. These fixes weren't saved — saving resumes when you're back online.` + String get liveTrackingErrorNoConnection { + return Intl.message( + 'No connection to the server. These fixes weren\'t saved — saving resumes when you\'re back online.', + name: 'liveTrackingErrorNoConnection', + desc: '', + args: [], + ); + } + + /// `Your session ended. Sign in again to keep saving location.` + String get liveTrackingErrorUnauthorized { + return Intl.message( + 'Your session ended. Sign in again to keep saving location.', + name: 'liveTrackingErrorUnauthorized', + desc: '', + args: [], + ); + } + + /// `Couldn't save this fix to the server. Saving retries with the next fix.` + String get liveTrackingErrorSaveFailed { + return Intl.message( + 'Couldn\'t save this fix to the server. Saving retries with the next fix.', + name: 'liveTrackingErrorSaveFailed', + desc: '', + args: [], + ); + } + + /// `Location services are turned off on this device.` + String get liveTrackingErrorServicesDisabled { + return Intl.message( + 'Location services are turned off on this device.', + name: 'liveTrackingErrorServicesDisabled', + desc: '', + args: [], + ); + } + + /// `Location permission was denied.` + String get liveTrackingErrorPermissionDenied { + return Intl.message( + 'Location permission was denied.', + name: 'liveTrackingErrorPermissionDenied', + desc: '', + args: [], + ); + } + + /// `Location permission is permanently denied. Enable it in the app settings.` + String get liveTrackingErrorPermissionDeniedForever { + return Intl.message( + 'Location permission is permanently denied. Enable it in the app settings.', + name: 'liveTrackingErrorPermissionDeniedForever', + desc: '', + args: [], + ); + } + + /// `Couldn't get a location fix.` + String get liveTrackingErrorLocation { + return Intl.message( + 'Couldn\'t get a location fix.', + name: 'liveTrackingErrorLocation', + desc: '', + args: [], + ); + } + /// `Live location tracking` String get liveTrackingMenuTitle { return Intl.message( diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 854b3f1d..0bdd4b62 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -487,6 +487,14 @@ "liveTrackingStarted": "Start time", "liveTrackingLastFix": "Last fix", "liveTrackingLastError": "Last error", + "liveTrackingErrorTargetNotFound": "Can't save to this entity — it no longer exists. Tracking continues; update the target in the dashboard action.", + "liveTrackingErrorNoConnection": "No connection to the server. These fixes weren't saved — saving resumes when you're back online.", + "liveTrackingErrorUnauthorized": "Your session ended. Sign in again to keep saving location.", + "liveTrackingErrorSaveFailed": "Couldn't save this fix to the server. Saving retries with the next fix.", + "liveTrackingErrorServicesDisabled": "Location services are turned off on this device.", + "liveTrackingErrorPermissionDenied": "Location permission was denied.", + "liveTrackingErrorPermissionDeniedForever": "Location permission is permanently denied. Enable it in the app settings.", + "liveTrackingErrorLocation": "Couldn't get a location fix.", "liveTrackingMenuTitle": "Live location tracking", "liveTrackingNoRecord": "No active tracking and no recent session.", "liveTrackingLastSession": "Last session", diff --git a/lib/locator.dart b/lib/locator.dart index 3827b7c9..4bc9f22c 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -17,9 +17,11 @@ import 'package:thingsboard_app/utils/services/layouts/layout_service.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/entity_name_resolver.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_notifications.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_remote.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_notifications.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_remote.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_store.dart'; import 'package:thingsboard_app/utils/services/loading_service/i_loading_service.dart'; @@ -80,6 +82,9 @@ Future setUpRootDependencies() async { ..registerLazySingleton( () => EntityNameResolver(clientService: getIt(), logger: getIt()), ) + ..registerLazySingleton( + () => LiveTrackingNotifications(logger: getIt()), + ) ..registerLazySingleton( () => LiveLocationTrackingService( locationService: getIt(), @@ -87,6 +92,7 @@ Future setUpRootDependencies() async { logger: getIt(), store: getIt(), nameResolver: getIt(), + notifications: getIt(), ), ) // ..registerLazySingleton(() => TbContext()) diff --git a/lib/main.dart b/lib/main.dart index a0b7c9b4..8d6ed9ad 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:developer'; import 'package:app_links/app_links.dart'; @@ -16,6 +17,7 @@ import 'package:thingsboard_app/firebase_options.dart'; import 'package:thingsboard_app/locator.dart'; import 'package:thingsboard_app/thingsboard_app.dart'; import 'package:thingsboard_app/utils/services/firebase/i_firebase_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_notifications.dart'; import 'package:thingsboard_app/utils/services/local_database/i_local_database_service.dart'; import 'package:universal_platform/universal_platform.dart'; @@ -24,9 +26,12 @@ Future main() async { WidgetsFlutterBinding.ensureInitialized(); FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); await Hive.initFlutter(); - SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark); + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark); Hive.registerAdapter(RegionAdapter()); await setUpRootDependencies(); + // A paused-session notification survives process death while the session + // itself does not, so drop any leftover from a previous run. + unawaited(getIt().clear()); if (UniversalPlatform.isAndroid) { await InAppWebViewController.setWebContentsDebuggingEnabled( kDebugMode || EnvironmentVariables.verbose, diff --git a/lib/modules/location_tracking/presentation/view/live_tracking_page.dart b/lib/modules/location_tracking/presentation/view/live_tracking_page.dart index 31ea155c..22021513 100644 --- a/lib/modules/location_tracking/presentation/view/live_tracking_page.dart +++ b/lib/modules/location_tracking/presentation/view/live_tracking_page.dart @@ -15,6 +15,7 @@ import 'package:thingsboard_app/utils/services/device_profile/device_profile_cac import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_display.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_error.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; import 'package:thingsboard_app/utils/services/overlay_service/i_overlay_service.dart'; import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; @@ -47,6 +48,24 @@ TextStyle _linkStyle(BuildContext context) { ); } +String _errorLabel( + BuildContext context, + LiveTrackingError error, +) => switch (error) { + LiveTrackingError.targetNotFound => + S.of(context).liveTrackingErrorTargetNotFound, + LiveTrackingError.noConnection => S.of(context).liveTrackingErrorNoConnection, + LiveTrackingError.unauthorized => S.of(context).liveTrackingErrorUnauthorized, + LiveTrackingError.saveFailed => S.of(context).liveTrackingErrorSaveFailed, + LiveTrackingError.locationServicesDisabled => + S.of(context).liveTrackingErrorServicesDisabled, + LiveTrackingError.locationPermissionDenied => + S.of(context).liveTrackingErrorPermissionDenied, + LiveTrackingError.locationPermissionDeniedForever => + S.of(context).liveTrackingErrorPermissionDeniedForever, + LiveTrackingError.locationError => S.of(context).liveTrackingErrorLocation, +}; + bool _targetHasDetailsPage(String entityType) => switch (entityType) { 'DEVICE' || 'ASSET' || 'CUSTOMER' => true, _ => false, @@ -224,7 +243,7 @@ class _ActiveSession extends ConsumerWidget { ListTile( title: Text(S.of(context).liveTrackingLastError), subtitle: Text( - session.lastError!, + _errorLabel(context, session.lastError!), style: TextStyle(color: Theme.of(context).colorScheme.error), ), ), diff --git a/lib/utils/services/live_location_tracking/i_live_tracking_notifications.dart b/lib/utils/services/live_location_tracking/i_live_tracking_notifications.dart new file mode 100644 index 00000000..3480df97 --- /dev/null +++ b/lib/utils/services/live_location_tracking/i_live_tracking_notifications.dart @@ -0,0 +1,9 @@ +/// Syncs the "session is paused" system notification with the live tracking +/// session state. The tracking-active state needs no handling here: on Android +/// the geolocator foreground service shows its own notification while the GPS +/// stream is running, and iOS shows the system location indicator instead. +abstract interface class ILiveTrackingNotifications { + Future showPaused({String? targetName}); + + Future clear(); +} diff --git a/lib/utils/services/live_location_tracking/live_location_tracking_service.dart b/lib/utils/services/live_location_tracking/live_location_tracking_service.dart index 9f669672..011f7605 100644 --- a/lib/utils/services/live_location_tracking/live_location_tracking_service.dart +++ b/lib/utils/services/live_location_tracking/live_location_tracking_service.dart @@ -3,10 +3,12 @@ import 'dart:async'; import 'package:thingsboard_app/core/logger/tb_logger.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_notifications.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_remote.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_error.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; @@ -20,6 +22,7 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { required TbLogger logger, required ILiveTrackingStore store, required IEntityNameResolver nameResolver, + required ILiveTrackingNotifications notifications, // Android notification strings are OS-level, set once at construction; // English defaults are acceptable for v1 (the locator can later pass // localized strings without touching this class). @@ -31,13 +34,15 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { _remote = remote, _log = logger, _store = store, - _nameResolver = nameResolver; + _nameResolver = nameResolver, + _notifications = notifications; final ILocationService _locationService; final ILiveTrackingRemote _remote; final TbLogger _log; final ILiveTrackingStore _store; final IEntityNameResolver _nameResolver; + final ILiveTrackingNotifications _notifications; final BackgroundTrackingConfig backgroundConfig; final _sessionController = StreamController.broadcast(); @@ -127,6 +132,7 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { _cancelSubscription(); final current = _session; if (current != null) { + await _notifications.clear(); await _writeTrackingStatus(active: false); await _updateRecordOnEnd(current, reason); _setSession(null); @@ -149,7 +155,7 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { saveErrorCount: session.saveErrorCount, lastLat: session.lastFix?.latitude, lastLng: session.lastFix?.longitude, - lastError: session.lastError, + lastError: session.lastError?.name, endReason: reason, ), ); @@ -163,6 +169,7 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { } _cancelSubscription(); _setSession(current.copyWith(status: LiveTrackingStatus.paused)); + await _notifications.showPaused(targetName: current.config.targetName); await _writeTrackingStatus(active: false); } @@ -175,6 +182,7 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { _setSession( current.copyWith(status: LiveTrackingStatus.tracking, lastError: null), ); + await _notifications.clear(); await _writeTrackingStatus(active: true, includeTrackedBy: true); _subscribe(current.config); } @@ -202,18 +210,28 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { } switch (fix) { case LocationSuccess(:final position): + // A successful fix means the previously reported problem is over; + // a still-failing save below re-raises its own error. _setSession( - current.copyWith(fixCount: current.fixCount + 1, lastFix: position), + current.copyWith( + fixCount: current.fixCount + 1, + lastFix: position, + lastError: null, + ), ); await _saveFix(current.config, position); case LocationServicesDisabled(): - await _pauseWithError('Location services are disabled.'); + await _pauseWithError(LiveTrackingError.locationServicesDisabled); case LocationPermissionDenied(): - await _pauseWithError('Location permission denied.'); + await _pauseWithError(LiveTrackingError.locationPermissionDenied); case LocationPermissionDeniedForever(): - await _pauseWithError('Location permission permanently denied.'); - case LocationFixError(:final message): - _setSession(_session?.copyWith(lastError: message)); + await _pauseWithError( + LiveTrackingError.locationPermissionDeniedForever, + ); + case LocationFixError(): + _setSession( + _session?.copyWith(lastError: LiveTrackingError.locationError), + ); } } @@ -239,22 +257,23 @@ class LiveLocationTrackingService implements ILiveLocationTrackingService { _setSession( current.copyWith( saveErrorCount: current.saveErrorCount + 1, - lastError: e.toString(), + lastError: LiveTrackingError.fromSaveException(e), ), ); } } } - Future _pauseWithError(String message) async { + Future _pauseWithError(LiveTrackingError error) async { final current = _session; if (current == null) { return; } _cancelSubscription(); _setSession( - current.copyWith(status: LiveTrackingStatus.paused, lastError: message), + current.copyWith(status: LiveTrackingStatus.paused, lastError: error), ); + await _notifications.showPaused(targetName: current.config.targetName); await _writeTrackingStatus(active: false); } diff --git a/lib/utils/services/live_location_tracking/live_tracking_notifications.dart b/lib/utils/services/live_location_tracking/live_tracking_notifications.dart new file mode 100644 index 00000000..cceecdce --- /dev/null +++ b/lib/utils/services/live_location_tracking/live_tracking_notifications.dart @@ -0,0 +1,83 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:thingsboard_app/config/routes/v2/routes_config/routes/location_tracking_routes.dart'; +import 'package:thingsboard_app/config/themes/app_colors.dart'; +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_notifications.dart'; + +/// Android-only ongoing notification shown while a live tracking session is +/// paused. While tracking is active the geolocator foreground service owns its +/// own notification; pausing cancels the GPS stream (and with it that +/// notification), so this one fills the gap. No-op on iOS, which has no +/// ongoing-notification concept. +/// +/// Notification strings are OS-level and English-only for v1, matching the +/// foreground-service strings in `BackgroundTrackingConfig`. +class LiveTrackingNotifications implements ILiveTrackingNotifications { + LiveTrackingNotifications({ + required TbLogger logger, + FlutterLocalNotificationsPlugin? plugin, + }) : _log = logger, + _plugin = plugin ?? FlutterLocalNotificationsPlugin(); + + final TbLogger _log; + final FlutterLocalNotificationsPlugin _plugin; + + static const _notificationId = 51001; + + /// Payload shape consumed by NotificationService's tap handler; deep-links + /// to the live tracking screen. + static final _payload = json.encode({ + 'enabled': true, + 'linkType': 'LINK', + 'link': LocationTrackingRoutes.liveTracking, + }); + + @override + Future showPaused({String? targetName}) async { + if (defaultTargetPlatform != TargetPlatform.android) { + return; + } + try { + await _plugin.show( + _notificationId, + 'ThingsBoard', + targetName == null + ? 'Live location tracking is paused' + : 'Live tracking of $targetName is paused', + NotificationDetails( + android: AndroidNotificationDetails( + 'live_tracking', + 'Live location tracking', + channelDescription: + 'Shows the state of the live location tracking session', + icon: '@drawable/ic_launcher_foreground', + color: AppColors.appPrimaryColor, + importance: Importance.low, + priority: Priority.low, + ongoing: true, + autoCancel: false, + showWhen: false, + ), + ), + payload: _payload, + ); + } catch (e, s) { + _log.error('LiveTrackingNotifications: show failed', e, s); + } + } + + @override + Future clear() async { + if (defaultTargetPlatform != TargetPlatform.android) { + return; + } + try { + await _plugin.cancel(_notificationId); + } catch (e, s) { + _log.error('LiveTrackingNotifications: cancel failed', e, s); + } + } +} diff --git a/lib/utils/services/live_location_tracking/model/live_tracking_error.dart b/lib/utils/services/live_location_tracking/model/live_tracking_error.dart new file mode 100644 index 00000000..e8c1dff8 --- /dev/null +++ b/lib/utils/services/live_location_tracking/model/live_tracking_error.dart @@ -0,0 +1,39 @@ +import 'package:thingsboard_app/thingsboard_client.dart'; + +/// Why the session is degraded, as a cause the UI layer can localize. +/// Raw exception text (stack traces, URLs, Angular/Dio wording) must never +/// reach the session screen — details go to the logger only. +enum LiveTrackingError { + targetNotFound, + noConnection, + unauthorized, + saveFailed, + locationServicesDisabled, + locationPermissionDenied, + locationPermissionDeniedForever, + locationError; + + /// Classifies a failed save. A deleted target arrives with an empty server + /// message (`[404: ]`), so causes are derived from status/error code — + /// server text is never passed through. + static LiveTrackingError fromSaveException(Object e) { + final error = toThingsboardError(e); + if (error.errorCode == ThingsBoardErrorCode.general && + error.message == 'Unable to connect') { + return noConnection; + } + // The interceptor reports a missing token as a bare 'Unauthorized!' + // message with no status or error code. + if (error.status == 401 || + error.message == 'Unauthorized!' || + error.errorCode == ThingsBoardErrorCode.authentication || + error.errorCode == ThingsBoardErrorCode.jwtTokenExpired) { + return unauthorized; + } + if (error.status == 404 || + error.errorCode == ThingsBoardErrorCode.itemNotFound) { + return targetNotFound; + } + return saveFailed; + } +} diff --git a/lib/utils/services/live_location_tracking/model/live_tracking_session.dart b/lib/utils/services/live_location_tracking/model/live_tracking_session.dart index 48c111df..e2382bee 100644 --- a/lib/utils/services/live_location_tracking/model/live_tracking_session.dart +++ b/lib/utils/services/live_location_tracking/model/live_tracking_session.dart @@ -1,5 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_error.dart'; import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; part 'live_tracking_session.freezed.dart'; @@ -16,6 +17,6 @@ abstract class LiveTrackingSession with _$LiveTrackingSession { @Default(0) int savedCount, @Default(0) int saveErrorCount, GeoPosition? lastFix, - String? lastError, + LiveTrackingError? lastError, }) = _LiveTrackingSession; } diff --git a/lib/utils/services/live_location_tracking/model/live_tracking_session.freezed.dart b/lib/utils/services/live_location_tracking/model/live_tracking_session.freezed.dart index 2a37132f..e3785607 100644 --- a/lib/utils/services/live_location_tracking/model/live_tracking_session.freezed.dart +++ b/lib/utils/services/live_location_tracking/model/live_tracking_session.freezed.dart @@ -14,7 +14,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$LiveTrackingSession { - LiveTrackingConfig get config; LiveTrackingStatus get status; DateTime get startedAt; int get fixCount; int get savedCount; int get saveErrorCount; GeoPosition? get lastFix; String? get lastError; + LiveTrackingConfig get config; LiveTrackingStatus get status; DateTime get startedAt; int get fixCount; int get savedCount; int get saveErrorCount; GeoPosition? get lastFix; LiveTrackingError? get lastError; /// Create a copy of LiveTrackingSession /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -45,7 +45,7 @@ abstract mixin class $LiveTrackingSessionCopyWith<$Res> { factory $LiveTrackingSessionCopyWith(LiveTrackingSession value, $Res Function(LiveTrackingSession) _then) = _$LiveTrackingSessionCopyWithImpl; @useResult $Res call({ - LiveTrackingConfig config, LiveTrackingStatus status, DateTime startedAt, int fixCount, int savedCount, int saveErrorCount, GeoPosition? lastFix, String? lastError + LiveTrackingConfig config, LiveTrackingStatus status, DateTime startedAt, int fixCount, int savedCount, int saveErrorCount, GeoPosition? lastFix, LiveTrackingError? lastError }); @@ -72,7 +72,7 @@ as int,savedCount: null == savedCount ? _self.savedCount : savedCount // ignore: as int,saveErrorCount: null == saveErrorCount ? _self.saveErrorCount : saveErrorCount // ignore: cast_nullable_to_non_nullable as int,lastFix: freezed == lastFix ? _self.lastFix : lastFix // ignore: cast_nullable_to_non_nullable as GeoPosition?,lastError: freezed == lastError ? _self.lastError : lastError // ignore: cast_nullable_to_non_nullable -as String?, +as LiveTrackingError?, )); } /// Create a copy of LiveTrackingSession @@ -169,7 +169,7 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( LiveTrackingConfig config, LiveTrackingStatus status, DateTime startedAt, int fixCount, int savedCount, int saveErrorCount, GeoPosition? lastFix, String? lastError)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( LiveTrackingConfig config, LiveTrackingStatus status, DateTime startedAt, int fixCount, int savedCount, int saveErrorCount, GeoPosition? lastFix, LiveTrackingError? lastError)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _LiveTrackingSession() when $default != null: return $default(_that.config,_that.status,_that.startedAt,_that.fixCount,_that.savedCount,_that.saveErrorCount,_that.lastFix,_that.lastError);case _: @@ -190,7 +190,7 @@ return $default(_that.config,_that.status,_that.startedAt,_that.fixCount,_that.s /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( LiveTrackingConfig config, LiveTrackingStatus status, DateTime startedAt, int fixCount, int savedCount, int saveErrorCount, GeoPosition? lastFix, String? lastError) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( LiveTrackingConfig config, LiveTrackingStatus status, DateTime startedAt, int fixCount, int savedCount, int saveErrorCount, GeoPosition? lastFix, LiveTrackingError? lastError) $default,) {final _that = this; switch (_that) { case _LiveTrackingSession(): return $default(_that.config,_that.status,_that.startedAt,_that.fixCount,_that.savedCount,_that.saveErrorCount,_that.lastFix,_that.lastError);case _: @@ -210,7 +210,7 @@ return $default(_that.config,_that.status,_that.startedAt,_that.fixCount,_that.s /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( LiveTrackingConfig config, LiveTrackingStatus status, DateTime startedAt, int fixCount, int savedCount, int saveErrorCount, GeoPosition? lastFix, String? lastError)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( LiveTrackingConfig config, LiveTrackingStatus status, DateTime startedAt, int fixCount, int savedCount, int saveErrorCount, GeoPosition? lastFix, LiveTrackingError? lastError)? $default,) {final _that = this; switch (_that) { case _LiveTrackingSession() when $default != null: return $default(_that.config,_that.status,_that.startedAt,_that.fixCount,_that.savedCount,_that.saveErrorCount,_that.lastFix,_that.lastError);case _: @@ -235,7 +235,7 @@ class _LiveTrackingSession implements LiveTrackingSession { @override@JsonKey() final int savedCount; @override@JsonKey() final int saveErrorCount; @override final GeoPosition? lastFix; -@override final String? lastError; +@override final LiveTrackingError? lastError; /// Create a copy of LiveTrackingSession /// with the given fields replaced by the non-null parameter values. @@ -267,7 +267,7 @@ abstract mixin class _$LiveTrackingSessionCopyWith<$Res> implements $LiveTrackin factory _$LiveTrackingSessionCopyWith(_LiveTrackingSession value, $Res Function(_LiveTrackingSession) _then) = __$LiveTrackingSessionCopyWithImpl; @override @useResult $Res call({ - LiveTrackingConfig config, LiveTrackingStatus status, DateTime startedAt, int fixCount, int savedCount, int saveErrorCount, GeoPosition? lastFix, String? lastError + LiveTrackingConfig config, LiveTrackingStatus status, DateTime startedAt, int fixCount, int savedCount, int saveErrorCount, GeoPosition? lastFix, LiveTrackingError? lastError }); @@ -294,7 +294,7 @@ as int,savedCount: null == savedCount ? _self.savedCount : savedCount // ignore: as int,saveErrorCount: null == saveErrorCount ? _self.saveErrorCount : saveErrorCount // ignore: cast_nullable_to_non_nullable as int,lastFix: freezed == lastFix ? _self.lastFix : lastFix // ignore: cast_nullable_to_non_nullable as GeoPosition?,lastError: freezed == lastError ? _self.lastError : lastError // ignore: cast_nullable_to_non_nullable -as String?, +as LiveTrackingError?, )); } diff --git a/lib/utils/services/location/location_service.dart b/lib/utils/services/location/location_service.dart index 150ca83a..eb85f650 100644 --- a/lib/utils/services/location/location_service.dart +++ b/lib/utils/services/location/location_service.dart @@ -32,7 +32,7 @@ class LocationService implements ILocationService { return LocationSuccess(_toGeoPosition(position)); } catch (e, s) { _log.error('LocationService.getCurrentPosition failed', e, s); - return LocationFixError(e.toString()); + return _fixFromPlatformError(e); } } @@ -57,12 +57,22 @@ class LocationService implements ILocationService { sink.add(LocationSuccess(_toGeoPosition(position))), handleError: (e, s, sink) { _log.error('LocationService.positionStream error', e, s); - sink.add(LocationFixError(e.toString())); + sink.add(_fixFromPlatformError(e)); }, ), ); } + /// Geolocator reports a mid-stream loss of location availability as a + /// stream *error*, not as a status; mapping it back to the sealed cases + /// keeps the "services disabled" / "permission denied" handling identical + /// whether it happens at start or mid-session. + LocationFix _fixFromPlatformError(Object e) => switch (e) { + LocationServiceDisabledException() => const LocationServicesDisabled(), + PermissionDeniedException() => const LocationPermissionDenied(), + _ => LocationFixError(e.toString()), + }; + @override Future openLocationSettings() => _geolocator.openLocationSettings(); diff --git a/test/utils/services/live_location_tracking/live_location_tracking_service_test.dart b/test/utils/services/live_location_tracking/live_location_tracking_service_test.dart index eb6e7cc9..aec0920f 100644 --- a/test/utils/services/live_location_tracking/live_location_tracking_service_test.dart +++ b/test/utils/services/live_location_tracking/live_location_tracking_service_test.dart @@ -4,11 +4,13 @@ import 'package:fake_async/fake_async.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:thingsboard_app/core/logger/tb_logger.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_notifications.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_remote.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/live_location_tracking_service.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_error.dart'; import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; @@ -75,6 +77,24 @@ class FakeNameResolver implements IEntityNameResolver { } } +class FakeNotifications implements ILiveTrackingNotifications { + final shownTargetNames = []; + int clearCount = 0; + bool pausedShown = false; + + @override + Future showPaused({String? targetName}) async { + shownTargetNames.add(targetName); + pausedShown = true; + } + + @override + Future clear() async { + clearCount++; + pausedShown = false; + } +} + class FakeRemote implements ILiveTrackingRemote { final telemetryCalls = <(LiveTrackingTarget, int, Map)>[]; final attributeCalls = <(LiveTrackingTarget, Map)>[]; @@ -147,17 +167,20 @@ void main() { List? keys, String? trackedBy, int? maxDurationSeconds, + String? targetName, }) => LiveTrackingConfig( target: target, keys: keys ?? positionKeys, trackedBy: trackedBy, maxDurationSeconds: maxDurationSeconds, + targetName: targetName, ); late FakeLocationService location; late FakeRemote remote; late FakeStore store; late FakeNameResolver nameResolver; + late FakeNotifications notifications; late LiveLocationTrackingService service; setUp(() { @@ -165,12 +188,14 @@ void main() { remote = FakeRemote(); store = FakeStore(); nameResolver = FakeNameResolver(); + notifications = FakeNotifications(); service = LiveLocationTrackingService( locationService: location, remote: remote, logger: TbLogger(), store: store, nameResolver: nameResolver, + notifications: notifications, ); }); @@ -283,9 +308,54 @@ void main() { expect(service.session?.status, LiveTrackingStatus.tracking); expect(service.session?.saveErrorCount, 1); expect(service.session?.savedCount, 0); - expect(service.session?.lastError, contains('boom')); + expect(service.session?.lastError, LiveTrackingError.saveFailed); + }); + + test('a successful fix clears the previous error', () async { + await service.start( + configOf( + keys: [ + _key( + LiveTrackingKeyType.latitude, + 'latitude', + LiveTrackingValueType.timeseries, + ), + ], + ), + ); + remote.throwOnTelemetry = Exception('boom'); + location.controller!.add(LocationSuccess(fix)); + await pumpEventQueue(); + expect(service.session?.lastError, LiveTrackingError.saveFailed); + + remote.throwOnTelemetry = null; + location.controller!.add(LocationSuccess(fix)); + await pumpEventQueue(); + + expect(service.session?.lastError, isNull); + expect(service.session?.savedCount, 1); + expect(service.session?.saveErrorCount, 1); }); + test( + 'services disabled mid-session pauses and writes gpsActive=false', + () async { + await service.start(configOf(keys: [...positionKeys, ...statusKeys])); + remote.attributeCalls.clear(); + + location.controller!.add(const LocationServicesDisabled()); + await pumpEventQueue(); + + expect(service.session?.status, LiveTrackingStatus.paused); + expect( + service.session?.lastError, + LiveTrackingError.locationServicesDisabled, + ); + expect(remote.attributeCalls.single.$2, {'gpsActive': false}); + expect(notifications.pausedShown, true); + }, + ); + test( 'pause cancels the stream and writes gpsActive=false; resume restores', () async { @@ -330,9 +400,31 @@ void main() { expect(service.session?.status, LiveTrackingStatus.paused); expect(service.session?.lastError, isNotNull); + expect(notifications.pausedShown, true); }, ); + test('pause shows the paused notification with the config target name ' + 'and resume clears it', () async { + await service.start(configOf(targetName: 'Car 42')); + + await service.pause(); + expect(notifications.pausedShown, true); + expect(notifications.shownTargetNames.single, 'Car 42'); + + await service.resume(); + expect(notifications.pausedShown, false); + }); + + test('stop while paused clears the paused notification', () async { + await service.start(configOf()); + await service.pause(); + expect(notifications.pausedShown, true); + + await service.stop(); + expect(notifications.pausedShown, false); + }); + test('maxDurationSeconds auto-stops the session', () { fakeAsync((async) { service.start(configOf(maxDurationSeconds: 300)); diff --git a/test/utils/services/live_location_tracking/live_tracking_error_test.dart b/test/utils/services/live_location_tracking/live_tracking_error_test.dart new file mode 100644 index 00000000..0499b68a --- /dev/null +++ b/test/utils/services/live_location_tracking/live_tracking_error_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/thingsboard_client.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_error.dart'; + +void main() { + group('LiveTrackingError.fromSaveException', () { + test('deleted target — 404 with empty server message — maps to ' + 'targetNotFound', () { + final e = ThingsboardError( + message: '404: ', + status: 404, + errorCode: ThingsBoardErrorCode.itemNotFound, + ); + + expect( + LiveTrackingError.fromSaveException(e), + LiveTrackingError.targetNotFound, + ); + }); + + test('itemNotFound code without an HTTP status maps to targetNotFound', () { + final e = ThingsboardError(errorCode: ThingsBoardErrorCode.itemNotFound); + + expect( + LiveTrackingError.fromSaveException(e), + LiveTrackingError.targetNotFound, + ); + }); + + test('connection failure maps to noConnection', () { + final e = ThingsboardError( + message: 'Unable to connect', + errorCode: ThingsBoardErrorCode.general, + ); + + expect( + LiveTrackingError.fromSaveException(e), + LiveTrackingError.noConnection, + ); + }); + + test('interceptor missing-token error — bare message, no status — maps ' + 'to unauthorized', () { + final e = ThingsboardError(message: 'Unauthorized!'); + + expect( + LiveTrackingError.fromSaveException(e), + LiveTrackingError.unauthorized, + ); + }); + + test('401 maps to unauthorized', () { + final e = ThingsboardError( + status: 401, + errorCode: ThingsBoardErrorCode.authentication, + ); + + expect( + LiveTrackingError.fromSaveException(e), + LiveTrackingError.unauthorized, + ); + }); + + test('expired JWT maps to unauthorized', () { + final e = ThingsboardError( + errorCode: ThingsBoardErrorCode.jwtTokenExpired, + ); + + expect( + LiveTrackingError.fromSaveException(e), + LiveTrackingError.unauthorized, + ); + }); + + test('an unrecognized exception maps to the generic saveFailed', () { + expect( + LiveTrackingError.fromSaveException(Exception('boom')), + LiveTrackingError.saveFailed, + ); + }); + + test('a 500 server error maps to the generic saveFailed', () { + final e = ThingsboardError(message: '500: Unknown', status: 500); + + expect( + LiveTrackingError.fromSaveException(e), + LiveTrackingError.saveFailed, + ); + }); + }); +} diff --git a/test/utils/services/location/location_service_test.dart b/test/utils/services/location/location_service_test.dart new file mode 100644 index 00000000..68480e6e --- /dev/null +++ b/test/utils/services/location/location_service_test.dart @@ -0,0 +1,77 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/utils/services/location/location_service.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; + +class FakeGeolocator extends GeolocatorPlatform { + final controller = StreamController(); + + @override + Future isLocationServiceEnabled() async => true; + + @override + Future checkPermission() async => + LocationPermission.whileInUse; + + @override + Stream getPositionStream({LocationSettings? locationSettings}) => + controller.stream; +} + +void main() { + late FakeGeolocator geolocator; + late LocationService service; + + setUp(() { + geolocator = FakeGeolocator(); + service = LocationService(logger: TbLogger(), geolocator: geolocator); + }); + + Future> collectAfter(void Function() act) async { + final fixes = []; + final sub = service.positionStream().listen(fixes.add); + await pumpEventQueue(); + act(); + await pumpEventQueue(); + await sub.cancel(); + return fixes; + } + + test( + 'services-disabled stream error surfaces as LocationServicesDisabled', + () async { + final fixes = await collectAfter( + () => geolocator.controller.addError( + const LocationServiceDisabledException(), + ), + ); + + expect(fixes.single, isA()); + }, + ); + + test( + 'permission-denied stream error surfaces as LocationPermissionDenied', + () async { + final fixes = await collectAfter( + () => geolocator.controller.addError( + const PermissionDeniedException('denied'), + ), + ); + + expect(fixes.single, isA()); + }, + ); + + test('other stream errors surface as LocationFixError', () async { + final fixes = await collectAfter( + () => geolocator.controller.addError(Exception('gps glitch')), + ); + + expect(fixes.single, isA()); + expect((fixes.single as LocationFixError).message, contains('gps glitch')); + }); +} From 03c2fffa4d29df0341322ead5f01308310f52e5b Mon Sep 17 00:00:00 2001 From: ababak Date: Fri, 31 Jul 2026 16:18:22 +0300 Subject: [PATCH 36/40] docs: single-entity location save + action editor label spec --- ...7-31-location-save-single-entity-design.md | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md diff --git a/docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md b/docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md new file mode 100644 index 00000000..d4f6131e --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md @@ -0,0 +1,208 @@ +# Single-Entity Location Save + Action Editor Labels — Design Spec + +**Status:** Design approved, ready for implementation planning. +**Repo:** `thingsboard` / `ui-ngx` (Angular frontend). **No Flutter app or backend changes** — see "Mobile app impact" below. +**Branch:** `feat/gps-tracker` (nothing here is released; see "Compatibility"). +**Origin:** Review of the mobile action editor for `Get phone location`, which surfaced (a) two different fields both labelled "Target entity", and (b) the question of whether multi-entity save is a feature worth keeping. + +## Goal + +Make **all three** location widget actions save to exactly **one** target entity, failing loudly when the configuration resolves to more, and clean up the action editor labels that the current wording made ambiguous. + +The three actions: + +| Action | Where | Multiplicity today | +|---|---|---| +| `getLocation` (with *Save location to entity* on) | `mobileAction` | up to 100 entities via alias fan-out | +| `saveBrowserLocation` | generic widget action | up to 100 entities via alias fan-out | +| `startLiveLocation` | `mobileAction` | 1 — silently takes the alphabetically first | + +After this change all three behave identically: **one target, or an error.** + +## Rationale + +**A location fix is an observation about one physical body** — the phone, or the browser's host machine. Fan-out writes that single observation as N identical observations. A dashboard consuming the result cannot distinguish *"these 40 assets are genuinely at this spot"* from *"someone picked the wrong alias"*: all N receive byte-identical lat/lng from one sensor, with no aggregation and no per-entity differentiation. Fan-out is meaningful for *intent* (a command, a setpoint); it is not meaningful for a *measurement* of one thing. + +**The multi path is easy to hit by accident, and it congratulates you for it.** Most useful alias filter types resolve to sets by nature (`deviceType`, `assetType`, `relationsQuery`, `entityList`); the single-resolving ones (`singleEntity`, `stateEntity`) are the minority. Today a user who picks a set-valued alias gets a green success toast — *"Browser location saved to 40 entities"* — confirming the mistake. Silent wrongness with positive feedback, at write scale. + +**The 100-entity cap shows it was never a designed capability.** `aliasTargetsPageSize = 100` (`location.service.ts:74`), `totalTargets = max(totalElements, targets.length)` (`:269`) and the `location-saved-partial` warning exist only to bound an accident. An alias resolving 5,000 entities writes to 100 of them, ordered alphabetically by name, and warns. That is truncation, not a feature. + +**Single → multi is the reversible direction.** Adding fan-out later is additive (restore `saveToTargets`, gate it behind an explicit opt-in). Removing it later breaks customer dashboards. With no use case in hand, take the reversible option. + +**The one real use case is served differently.** Co-moving targets — a technician's phone writing to both their user entity and "Van 12" — wants 2–3 *deliberately named* targets, not an alias that happens to resolve to 2. That is a separate, cheaper feature if it is ever requested. Recorded under "Out of scope". + +## Compatibility + +**Nothing is shipped, so there is no migration.** `location.service.ts` was introduced in `f204f3f778b`; `git branch --contains` on that commit lists only `feat/gps-tracker`. On `master`, `WidgetMobileActionType.getLocation` carries only `getLocationFunction` — no `targetEntity`, no `saveToEntity`, no key mappings — and `saveBrowserLocation` does not exist. No dashboard in the wild can depend on fan-out. + +## Behaviour: one target, two layers of feedback + +Runtime resolution can only be checked at click time, so a runtime error is unavoidable. But `resolveMultiple` is a **static property of the alias filter** (`EntityAliasFilter.resolveMultiple`, `alias.models.ts:185-188`), not data-dependent, and the editor already holds every alias definition. So the misconfiguration is caught earlier as well. + +### Layer 1 — config-time warning (soft, non-blocking) + +In `LocationTargetEntityComponent`, when the selected alias's filter has `resolveMultiple === true`, show a warning next to the *Alias name* row: + +> This alias can resolve to multiple entities. Saving will fail unless it resolves to exactly one. + +**Soft, not a validation error.** A `deviceType` alias may legitimately match exactly one device today; blocking Save would be wrong. It informs, it does not prevent. + +The component already fetches the data it needs — `this.entityAliases = this.callbacks?.fetchEntityAliases?.()` (`location-target-entity.component.ts:155`) — and already looks aliases up by name in `updateAttributeSourceEntityFilter` (`:232-247`). Reuse that lookup; both `source` and `aliasName` already have `valueChanges` subscriptions to hang recomputation on. + +**Applies in both modes.** In *From attribute* mode the alias identifies the entity the attribute is *read from*, and `resolveAttributeSourceEntity` (`location.service.ts:346`) resolves it through the same single-alias path. A set-valued alias is equally ambiguous there. Since the warning attaches to the shared *Alias name* row (`location-target-entity.component.html:49-82`), one implementation covers both. + +### Layer 2 — runtime error (hard) + +`resolveSingleEntityAlias` (`location.service.ts:359`) currently queries `findAliasEntities(aliasInfo, 1)` and takes `page.data[0]`, which is precisely what makes over-resolution invisible. Change it to request **pageSize 2** and branch on the count: + +| Resolved count | Outcome | +|---|---| +| 0 | existing `error-alias-not-resolved` | +| 1 | proceed | +| > 1 | new `error-alias-multiple`, reporting the count | + +Count as `max(page.totalElements ?? 0, page.data.length)` so the message reports the true total rather than the page size when the server supplies it. + +Two notes on `findAliasEntities` (`:286`): the page size only affects its `resolveMultiple === true` branch — a non-multiple alias short-circuits to `aliasInfo.currentEntity` as a one-element page and can never trip the new error. And with `resolveEntityAliasTargets` deleted it drops to a single caller, so the `pageSize` parameter may be inlined as a constant if the implementer prefers. + +Rename the method to `resolveEntityAlias` — with fan-out gone, "single" is no longer a distinguishing qualifier. + +This one change covers all three actions, because every path now funnels through `resolveTargetEntity` (`:317`). + +## Code changes + +### `ui-ngx/src/app/core/services/location.service.ts` + +**Delete:** + +- `LocationTargetResolution` interface (`:68-71`) +- `resolveTargets` (`:240-251`) and its `resolveNames` parameter +- `resolveEntityAliasTargets` (`:253-273`) +- `saveToTargets` (`:429-443`) +- `aliasTargetsPageSize` (`:74`) and `saveConcurrency` (`:77`) +- the now-unused `from` / `mergeMap` / `toArray` imports if nothing else needs them + +**Rewire the two savers** to `resolveTargetEntity` → `saveKeys` (`:445`), which already writes attributes and time series for one entity: + +- `saveMobileActionLocation` (`:95`) keeps `resolveTargetEntityName` (`:373`) — `LiveTrackingSaveInfo.targetName` needs it — and its `targetName` becomes that single name instead of a joined list. +- `saveBrowserLocation` (`:125`) never needed the name (it passed `resolveNames = false`), so it calls `resolveTargetEntity` → `saveKeys` and skips name resolution entirely. + +**Simplify the browser toasts** (`:156-181`): the partial and multi branches go away, leaving `location-saved-keys` when keys were written and `location-saved` otherwise. A failed save keeps its existing error toast. + +`liveTrackingArgs` (`:186`) needs **no change** — it already calls `resolveTargetEntity` and therefore inherits the new error for free. Its previous silent alphabetically-first pick was the same class of defect as silent fan-out; this fixes it as a side effect. + +### `location-target-entity.component.ts` / `.html` + +- Add the config-time warning described above. +- **Remove the `panelHint` input** (`.ts:114-115`) and the `@if (panelHint)` branch in the template (`.html:20-24`). Its only caller was the live-tracking hint, which this design deletes; the panel title collapses to the unconditional form. +- Split the double-used title/label key (see Labels). + +### `mobile-action-editor.component.html` + +- Drop `panelHint="..."` from the `startLiveLocation` usage (`:85`). +- Apply the new label keys (toggle `:68`, keys-table titles `:75` and `:88`). + +### `save-browser-location-action-editor.component.html` + +- Apply the new keys-table title key (`:21`). + +## Labels + +**Note on justification.** Once saving is single-entity, *"to entity"* is no longer factually wrong — it becomes accurate. These renames are therefore a **clarity** change, not a correctness fix. The genuine defect is the duplication: the panel title and the source row label are the *same* i18n key, `widget-action.mobile.target-entity-type`, rendered at `location-target-entity.component.html:21`/`:23` and again at `:36-37`. That must be split regardless. + +| Element | Key | Now | New | +|---|---|---|---| +| Save toggle (`getLocation`) | `widget-action.mobile.save-to-entity` | Save location to entity | **Save location** | +| Panel title | *new:* `widget-action.mobile.target-panel-title` | — | **Target** | +| Source row label | `target-entity-type` → *rename to* `target-save-to` | Target entity | **Save to** | +| Source dropdown option | `widget-action.mobile.target-current-entity` | Current datasource | **Current entity** | +| Keys table title | `widget-action.location.saved-keys` | Keys that are saved to entity | **Keys to save** | + +Renaming the key `target-entity-type` → `target-save-to` is a two-line change (the key is referenced only in that one template, never in `location.models.ts`) and avoids leaving a key named `-entity-type` holding the text "Save to". + +Resulting panel for *Get phone location*: + +``` +Save location ⏺ + Target [ Entity | From attribute ] + Save to [ Current entity ▾ ] + Alias name [ … ] ← warning here when alias is set-valued + ┌ Keys to save ──────────────────────┐ + │ Argument │ Data key │ Type │ +``` + +In *From attribute* mode the row label continues to switch to `target-attribute-source` ("Read attribute from"), unchanged. + +### Why "Current entity" for `CURRENT_ENTITY` + +`"Current datasource"` is a single-use string that names a *datasource* when the value is an *entity*. `CURRENT_ENTITY` resolves to `widgetContext.activeEntityInfo`, falling back to the first entity of the widget's own datasource subscription (`widget.component.ts:1793`) — i.e. **the entity the action fired on**: the clicked table row or map marker, else the widget's first datasource entity. + +Two rejected alternatives: + +- **"Entity from dashboard state"** — already taken. It is `alias.filter-type-state-entity` (*"Entity taken from dashboard state parameters"*, `AliasFilterType.stateEntity`), an unrelated mechanism. `CURRENT_ENTITY` never reads state params. +- **"Entity from widget datasource"** — sounds precise but is wrong whenever `activeEntityInfo` is set: clicking a table row yields that row's entity, not the datasource's first. + +`"Current entity"` matches terminology the product already uses for exactly this concept — `calculated-fields.argument-current` = `"Current entity"` — and the existing error string already reads *"Widget action has no current entity"*. + +## Localization + +`locale.constant-en_US.json` **only**. Verified: no other `locale.constant-*.json` contains `target-current-entity`, so none of these keys have been translated yet. + +**Add:** + +- `widget-action.location.error-alias-multiple` — *"Entity alias '{{alias}}' resolves to {{count}} entities. The location can only be saved to a single entity."* +- `widget-action.mobile.target-alias-multiple-warning` — *"This alias can resolve to multiple entities. Saving will fail unless it resolves to exactly one."* +- `widget-action.mobile.target-panel-title` — *"Target"* + +**Rename:** `widget-action.mobile.target-entity-type` → `widget-action.mobile.target-save-to`, new value *"Save to"*. Its three template references (`location-target-entity.component.html:21`, `:23`, `:37`) collapse to one: `:21`/`:23` are the two `panelHint` branches that this design merges into a single unconditional title using the new `target-panel-title` key, leaving `:37` as the only `target-save-to` use. + +**Change in place:** `widget-action.mobile.save-to-entity` → *"Save location"*; `widget-action.mobile.target-current-entity` → *"Current entity"*; `widget-action.location.saved-keys` → *"Keys to save"*. + +**Remove:** + +- `widget-action.browser-location.location-saved-keys-multi` +- `widget-action.browser-location.location-saved-partial` +- `widget-action.mobile.live-location-alias-hint` — asserted *"…uses the first one"*, which this design replaces with an error + +## Mobile app impact + +**None.** The Flutter app already models exactly one target: `LiveTrackingConfig.target` is a single `LiveTrackingTarget` (`lib/utils/services/live_location_tracking/model/live_tracking_config.dart:122-179`), and `_save` writes one telemetry and one attributes request per fix (`live_location_tracking_service.dart:310-339`). The web side already sends a single `target: {entityType, id}` (`location.service.ts:191-194`). This design makes the web editor and the one-shot savers consistent with what the app already does — the wire protocol is unchanged. + +## Error handling + +| Condition | Message | Surface | +|---|---|---| +| Alias not present on dashboard | `error-alias-not-found` (existing) | error toast | +| Alias resolves to 0 | `error-alias-not-resolved` (existing) | error toast | +| Alias resolves to >1 | `error-alias-multiple` (**new**) | error toast | +| `CURRENT_ENTITY` with no active entity | `error-no-current-entity` (existing) | error toast | +| Attribute-source problems | existing `error-attribute-*` family | error toast | +| Save request fails | `location-save-failed` (existing, per action) | error toast | + +Wrapping is unchanged: `saveMobileActionLocation` catches into a toast and completes without emitting (`:115-119`); `saveBrowserLocation` reports through its `subscribe` error handler (`:172-180`). + +For `startLiveLocation` the resolution error surfaces before the bridge call, so no session starts — which is the correct outcome and matches how other `liveTrackingArgs` failures already behave. + +## Testing + +`ui-ngx` has one `.spec.ts` in the entire tree (`auth.service.spec.ts`), so unit tests are not the convention here. Verify manually, in the style of `docs/testing/`: + +**Per action** — `getLocation` + *Save location*, `saveBrowserLocation`, `startLiveLocation`: + +1. `Current entity` on an entity widget → saves to that entity; label reads "Current entity". +2. `Current user` → saves to the user entity. +3. Alias with `resolveMultiple: false` → saves to the one entity, no warning shown. +4. Alias with `resolveMultiple: true` resolving to exactly **1** → **warning shown in editor, save succeeds**. This is the case that must not regress into a block. +5. Alias with `resolveMultiple: true` resolving to **many** → warning in editor; at click time an error toast naming the alias and the count; **no writes land on any entity**. +6. `From attribute` with a set-valued alias as the attribute source → same warning, same runtime error. + +**Editor:** panel title and source row read differently ("Target" / "Save to"); no live-tracking tooltip on the panel title for `startLiveLocation`; keys table titled "Keys to save" in all three editors. + +**Regression:** an action configured before this change (target + keys already stored) still loads and saves — the stored `MobileActionTargetEntityConfig` shape is untouched. + +## Out of scope + +- **Explicit multi-target save** (a small, deliberately named target list, capped low). The plausible co-move use case; revisit only on a concrete request. +- **Multi-entity live tracking** — remains out of scope for the reasons in `2026-07-22-gps-live-tracking-ux-design.md:17`, now reinforced: 2N requests per fix for the whole session duration, and contested `GPS_ACTIVE` / `GPS_TRACKED_BY` flags between overlapping sessions. +- **Translating the new strings** into the other 27 locale files. +- **Mid-session alias re-resolution.** From d2fdd6601f46e8bb247ef8a082665a63e7b39075 Mon Sep 17 00:00:00 2001 From: ababak Date: Fri, 31 Jul 2026 16:32:58 +0300 Subject: [PATCH 37/40] docs: narrow location label rename to the duplicated key --- ...7-31-location-save-single-entity-design.md | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md b/docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md index d4f6131e..098f2669 100644 --- a/docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md +++ b/docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md @@ -99,35 +99,36 @@ This one change covers all three actions, because every path now funnels through ### `mobile-action-editor.component.html` -- Drop `panelHint="..."` from the `startLiveLocation` usage (`:85`). -- Apply the new label keys (toggle `:68`, keys-table titles `:75` and `:88`). +- Drop `panelHint="..."` from the `startLiveLocation` usage (`:85`). Nothing else changes here — the toggle (`:68`) and both keys-table titles (`:75`, `:88`) keep their current keys and text. ### `save-browser-location-action-editor.component.html` -- Apply the new keys-table title key (`:21`). +- No changes. Its keys-table title (`:21`) is unaffected, and it never passed `panelHint`. ## Labels -**Note on justification.** Once saving is single-entity, *"to entity"* is no longer factually wrong — it becomes accurate. These renames are therefore a **clarity** change, not a correctness fix. The genuine defect is the duplication: the panel title and the source row label are the *same* i18n key, `widget-action.mobile.target-entity-type`, rendered at `location-target-entity.component.html:21`/`:23` and again at `:36-37`. That must be split regardless. +**Scope: duplication only.** Once saving is single-entity, *"to entity"* is no longer factually wrong — it becomes accurate. So the wordiness-driven renames are dropped: **`save-to-entity` ("Save location to entity") and `location.saved-keys` ("Keys that are saved to entity") stay exactly as they are.** + +What remains is the one genuine defect: the panel title and the source row label are the *same* i18n key, `widget-action.mobile.target-entity-type`, rendered at `location-target-entity.component.html:21`/`:23` and again at `:36-37`. Splitting it needs two distinct strings, so both sides of the split are in scope. The `CURRENT_ENTITY` option rename stays in too — `"Current datasource"` names a datasource when the value is an entity, which is a separate accuracy problem from the duplication. | Element | Key | Now | New | |---|---|---|---| -| Save toggle (`getLocation`) | `widget-action.mobile.save-to-entity` | Save location to entity | **Save location** | +| Save toggle (`getLocation`) | `widget-action.mobile.save-to-entity` | Save location to entity | *unchanged* | | Panel title | *new:* `widget-action.mobile.target-panel-title` | — | **Target** | | Source row label | `target-entity-type` → *rename to* `target-save-to` | Target entity | **Save to** | | Source dropdown option | `widget-action.mobile.target-current-entity` | Current datasource | **Current entity** | -| Keys table title | `widget-action.location.saved-keys` | Keys that are saved to entity | **Keys to save** | +| Keys table title | `widget-action.location.saved-keys` | Keys that are saved to entity | *unchanged* | Renaming the key `target-entity-type` → `target-save-to` is a two-line change (the key is referenced only in that one template, never in `location.models.ts`) and avoids leaving a key named `-entity-type` holding the text "Save to". Resulting panel for *Get phone location*: ``` -Save location ⏺ +Save location to entity ⏺ Target [ Entity | From attribute ] Save to [ Current entity ▾ ] Alias name [ … ] ← warning here when alias is set-valued - ┌ Keys to save ──────────────────────┐ + ┌ Keys that are saved to entity ─────┐ │ Argument │ Data key │ Type │ ``` @@ -156,7 +157,7 @@ Two rejected alternatives: **Rename:** `widget-action.mobile.target-entity-type` → `widget-action.mobile.target-save-to`, new value *"Save to"*. Its three template references (`location-target-entity.component.html:21`, `:23`, `:37`) collapse to one: `:21`/`:23` are the two `panelHint` branches that this design merges into a single unconditional title using the new `target-panel-title` key, leaving `:37` as the only `target-save-to` use. -**Change in place:** `widget-action.mobile.save-to-entity` → *"Save location"*; `widget-action.mobile.target-current-entity` → *"Current entity"*; `widget-action.location.saved-keys` → *"Keys to save"*. +**Change in place:** `widget-action.mobile.target-current-entity` → *"Current entity"*. `save-to-entity` and `location.saved-keys` are deliberately left alone (see Labels). **Remove:** @@ -196,7 +197,7 @@ For `startLiveLocation` the resolution error surfaces before the bridge call, so 5. Alias with `resolveMultiple: true` resolving to **many** → warning in editor; at click time an error toast naming the alias and the count; **no writes land on any entity**. 6. `From attribute` with a set-valued alias as the attribute source → same warning, same runtime error. -**Editor:** panel title and source row read differently ("Target" / "Save to"); no live-tracking tooltip on the panel title for `startLiveLocation`; keys table titled "Keys to save" in all three editors. +**Editor:** panel title and source row read differently ("Target" / "Save to") in all three editors; no live-tracking tooltip on the panel title for `startLiveLocation`. **Regression:** an action configured before this change (target + keys already stored) still loads and saves — the stored `MobileActionTargetEntityConfig` shape is untouched. From bfac17c12e8c48bf94b3363dbcaceb531a735386 Mon Sep 17 00:00:00 2001 From: ababak Date: Fri, 31 Jul 2026 17:24:16 +0300 Subject: [PATCH 38/40] docs: keep Current datasource label in location action editor --- ...7-31-location-save-single-entity-design.md | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md b/docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md index 098f2669..91223675 100644 --- a/docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md +++ b/docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md @@ -109,14 +109,14 @@ This one change covers all three actions, because every path now funnels through **Scope: duplication only.** Once saving is single-entity, *"to entity"* is no longer factually wrong — it becomes accurate. So the wordiness-driven renames are dropped: **`save-to-entity` ("Save location to entity") and `location.saved-keys` ("Keys that are saved to entity") stay exactly as they are.** -What remains is the one genuine defect: the panel title and the source row label are the *same* i18n key, `widget-action.mobile.target-entity-type`, rendered at `location-target-entity.component.html:21`/`:23` and again at `:36-37`. Splitting it needs two distinct strings, so both sides of the split are in scope. The `CURRENT_ENTITY` option rename stays in too — `"Current datasource"` names a datasource when the value is an entity, which is a separate accuracy problem from the duplication. +What remains is the one genuine defect: the panel title and the source row label are the *same* i18n key, `widget-action.mobile.target-entity-type`, rendered at `location-target-entity.component.html:21`/`:23` and again at `:36-37`. Splitting it needs two distinct strings, so both sides of the split are in scope. **Nothing else changes** — the `CURRENT_ENTITY` option keeps its existing `"Current datasource"` label (see below). | Element | Key | Now | New | |---|---|---|---| | Save toggle (`getLocation`) | `widget-action.mobile.save-to-entity` | Save location to entity | *unchanged* | | Panel title | *new:* `widget-action.mobile.target-panel-title` | — | **Target** | | Source row label | `target-entity-type` → *rename to* `target-save-to` | Target entity | **Save to** | -| Source dropdown option | `widget-action.mobile.target-current-entity` | Current datasource | **Current entity** | +| Source dropdown option | `widget-action.mobile.target-current-entity` | Current datasource | *unchanged* | | Keys table title | `widget-action.location.saved-keys` | Keys that are saved to entity | *unchanged* | Renaming the key `target-entity-type` → `target-save-to` is a two-line change (the key is referenced only in that one template, never in `location.models.ts`) and avoids leaving a key named `-entity-type` holding the text "Save to". @@ -126,7 +126,7 @@ Resulting panel for *Get phone location*: ``` Save location to entity ⏺ Target [ Entity | From attribute ] - Save to [ Current entity ▾ ] + Save to [ Current datasource ▾ ] Alias name [ … ] ← warning here when alias is set-valued ┌ Keys that are saved to entity ─────┐ │ Argument │ Data key │ Type │ @@ -134,16 +134,15 @@ Save location to entity ⏺ In *From attribute* mode the row label continues to switch to `target-attribute-source` ("Read attribute from"), unchanged. -### Why "Current entity" for `CURRENT_ENTITY` +### `CURRENT_ENTITY` keeps "Current datasource" -`"Current datasource"` is a single-use string that names a *datasource* when the value is an *entity*. `CURRENT_ENTITY` resolves to `widgetContext.activeEntityInfo`, falling back to the first entity of the widget's own datasource subscription (`widget.component.ts:1793`) — i.e. **the entity the action fired on**: the clicked table row or map marker, else the widget's first datasource entity. +Decided to leave this label alone. For the record, what the option actually resolves to: `widgetContext.activeEntityInfo`, falling back to the first entity of the widget's own datasource subscription (`widget.component.ts:1793`) — i.e. **the entity the action fired on**: the clicked table row or map marker, else the widget's first datasource entity. -Two rejected alternatives: +Alternatives considered and rejected: -- **"Entity from dashboard state"** — already taken. It is `alias.filter-type-state-entity` (*"Entity taken from dashboard state parameters"*, `AliasFilterType.stateEntity`), an unrelated mechanism. `CURRENT_ENTITY` never reads state params. +- **"Entity from dashboard state"** — already taken, by an unrelated mechanism: `alias.filter-type-state-entity` (*"Entity taken from dashboard state parameters"*, `AliasFilterType.stateEntity`). `CURRENT_ENTITY` never reads state params. - **"Entity from widget datasource"** — sounds precise but is wrong whenever `activeEntityInfo` is set: clicking a table row yields that row's entity, not the datasource's first. - -`"Current entity"` matches terminology the product already uses for exactly this concept — `calculated-fields.argument-current` = `"Current entity"` — and the existing error string already reads *"Widget action has no current entity"*. +- **"Current entity"** — matches `calculated-fields.argument-current` and the existing *"Widget action has no current entity"* error, but was not adopted; `"Current datasource"` stays as the established label for this dropdown. ## Localization @@ -157,7 +156,7 @@ Two rejected alternatives: **Rename:** `widget-action.mobile.target-entity-type` → `widget-action.mobile.target-save-to`, new value *"Save to"*. Its three template references (`location-target-entity.component.html:21`, `:23`, `:37`) collapse to one: `:21`/`:23` are the two `panelHint` branches that this design merges into a single unconditional title using the new `target-panel-title` key, leaving `:37` as the only `target-save-to` use. -**Change in place:** `widget-action.mobile.target-current-entity` → *"Current entity"*. `save-to-entity` and `location.saved-keys` are deliberately left alone (see Labels). +**Change in place:** none. `save-to-entity`, `target-current-entity` and `location.saved-keys` are all deliberately left alone (see Labels). **Remove:** @@ -190,7 +189,7 @@ For `startLiveLocation` the resolution error surfaces before the bridge call, so **Per action** — `getLocation` + *Save location*, `saveBrowserLocation`, `startLiveLocation`: -1. `Current entity` on an entity widget → saves to that entity; label reads "Current entity". +1. `Current datasource` on an entity widget → saves to the widget's active/first datasource entity. 2. `Current user` → saves to the user entity. 3. Alias with `resolveMultiple: false` → saves to the one entity, no warning shown. 4. Alias with `resolveMultiple: true` resolving to exactly **1** → **warning shown in editor, save succeeds**. This is the case that must not regress into a block. From 3021c3452737a604e541f970d4602f148372de5f Mon Sep 17 00:00:00 2001 From: ababak Date: Fri, 31 Jul 2026 17:36:28 +0300 Subject: [PATCH 39/40] docs: rename CURRENT_ENTITY option to Entity from widget datasource --- ...7-31-location-save-single-entity-design.md | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md b/docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md index 91223675..c3ac933a 100644 --- a/docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md +++ b/docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md @@ -109,14 +109,14 @@ This one change covers all three actions, because every path now funnels through **Scope: duplication only.** Once saving is single-entity, *"to entity"* is no longer factually wrong — it becomes accurate. So the wordiness-driven renames are dropped: **`save-to-entity` ("Save location to entity") and `location.saved-keys` ("Keys that are saved to entity") stay exactly as they are.** -What remains is the one genuine defect: the panel title and the source row label are the *same* i18n key, `widget-action.mobile.target-entity-type`, rendered at `location-target-entity.component.html:21`/`:23` and again at `:36-37`. Splitting it needs two distinct strings, so both sides of the split are in scope. **Nothing else changes** — the `CURRENT_ENTITY` option keeps its existing `"Current datasource"` label (see below). +The genuine defect is the duplication: the panel title and the source row label are the *same* i18n key, `widget-action.mobile.target-entity-type`, rendered at `location-target-entity.component.html:21`/`:23` and again at `:36-37`. Splitting it needs two distinct strings, so both sides of the split are in scope. The `CURRENT_ENTITY` dropdown option is also renamed, for accuracy rather than brevity (see below). | Element | Key | Now | New | |---|---|---|---| | Save toggle (`getLocation`) | `widget-action.mobile.save-to-entity` | Save location to entity | *unchanged* | | Panel title | *new:* `widget-action.mobile.target-panel-title` | — | **Target** | | Source row label | `target-entity-type` → *rename to* `target-save-to` | Target entity | **Save to** | -| Source dropdown option | `widget-action.mobile.target-current-entity` | Current datasource | *unchanged* | +| Source dropdown option | `widget-action.mobile.target-current-entity` | Current datasource | **Entity from widget datasource** | | Keys table title | `widget-action.location.saved-keys` | Keys that are saved to entity | *unchanged* | Renaming the key `target-entity-type` → `target-save-to` is a two-line change (the key is referenced only in that one template, never in `location.models.ts`) and avoids leaving a key named `-entity-type` holding the text "Save to". @@ -126,7 +126,7 @@ Resulting panel for *Get phone location*: ``` Save location to entity ⏺ Target [ Entity | From attribute ] - Save to [ Current datasource ▾ ] + Save to [ Entity from widget datasource ▾ ] Alias name [ … ] ← warning here when alias is set-valued ┌ Keys that are saved to entity ─────┐ │ Argument │ Data key │ Type │ @@ -134,15 +134,22 @@ Save location to entity ⏺ In *From attribute* mode the row label continues to switch to `target-attribute-source` ("Read attribute from"), unchanged. -### `CURRENT_ENTITY` keeps "Current datasource" +### Why "Entity from widget datasource" for `CURRENT_ENTITY` -Decided to leave this label alone. For the record, what the option actually resolves to: `widgetContext.activeEntityInfo`, falling back to the first entity of the widget's own datasource subscription (`widget.component.ts:1793`) — i.e. **the entity the action fired on**: the clicked table row or map marker, else the widget's first datasource entity. +`"Current datasource"` had two problems: `"current"` never said *whose* datasource on a dashboard full of widgets, and it named a *datasource* when the value is an *entity* — a datasource is a query that yields entities, not an entity itself. + +The new label is accurate on every dispatch path, all of which take the entity from the widget's own datasource data: + +- **Row-level actions** pass the clicked row's own `entity.id` straight into `handleWidgetAction` as `entityId` (`entities-table-widget.component.ts:804-808`, plus the timeseries-table cell and flot data-point equivalents), which becomes `currentEntityId` in the resolver. +- **Widget-level actions** (header action, widget click) go through `getActiveEntityInfo()` (`widget.component.ts:1793`), which reads `widgetContext.activeEntityInfo` — assigned in exactly one place in the tree, `timeseries-table-widget.component.ts:645`, from `this.sources[this.sourceIndex].datasource`, i.e. the currently selected datasource tab — and otherwise falls back to `subscription.getFirstEntityInfo()`. + +It also parallels the naming of the existing `stateEntity` alias filter, `"Entity from dashboard state"`. Alternatives considered and rejected: - **"Entity from dashboard state"** — already taken, by an unrelated mechanism: `alias.filter-type-state-entity` (*"Entity taken from dashboard state parameters"*, `AliasFilterType.stateEntity`). `CURRENT_ENTITY` never reads state params. -- **"Entity from widget datasource"** — sounds precise but is wrong whenever `activeEntityInfo` is set: clicking a table row yields that row's entity, not the datasource's first. -- **"Current entity"** — matches `calculated-fields.argument-current` and the existing *"Widget action has no current entity"* error, but was not adopted; `"Current datasource"` stays as the established label for this dropdown. +- **"Current entity"** — matches `calculated-fields.argument-current` and the existing *"Widget action has no current entity"* error string, but keeps the vague `"current"` qualifier. +- **"Widget datasource"** — shorter and accurate about *whose* datasource, but still names a datasource when the value is an entity. ## Localization @@ -156,7 +163,7 @@ Alternatives considered and rejected: **Rename:** `widget-action.mobile.target-entity-type` → `widget-action.mobile.target-save-to`, new value *"Save to"*. Its three template references (`location-target-entity.component.html:21`, `:23`, `:37`) collapse to one: `:21`/`:23` are the two `panelHint` branches that this design merges into a single unconditional title using the new `target-panel-title` key, leaving `:37` as the only `target-save-to` use. -**Change in place:** none. `save-to-entity`, `target-current-entity` and `location.saved-keys` are all deliberately left alone (see Labels). +**Change in place:** `widget-action.mobile.target-current-entity` → *"Entity from widget datasource"*. `save-to-entity` and `location.saved-keys` are deliberately left alone (see Labels). **Remove:** @@ -189,7 +196,7 @@ For `startLiveLocation` the resolution error surfaces before the bridge call, so **Per action** — `getLocation` + *Save location*, `saveBrowserLocation`, `startLiveLocation`: -1. `Current datasource` on an entity widget → saves to the widget's active/first datasource entity. +1. `Entity from widget datasource` on an entity widget → saves to the widget's active/first datasource entity; on a table row action, to the clicked row's entity. 2. `Current user` → saves to the user entity. 3. Alias with `resolveMultiple: false` → saves to the one entity, no warning shown. 4. Alias with `resolveMultiple: true` resolving to exactly **1** → **warning shown in editor, save succeeds**. This is the case that must not regress into a block. From 342288d55aa6183cd9ae2c2541f3885df0a99c77 Mon Sep 17 00:00:00 2001 From: ababak Date: Fri, 31 Jul 2026 17:55:15 +0300 Subject: [PATCH 40/40] docs: surface live tracking start failures as a toast --- ...6-07-31-location-save-single-entity-design.md | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md b/docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md index c3ac933a..ad985b43 100644 --- a/docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md +++ b/docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md @@ -89,7 +89,7 @@ This one change covers all three actions, because every path now funnels through **Simplify the browser toasts** (`:156-181`): the partial and multi branches go away, leaving `location-saved-keys` when keys were written and `location-saved` otherwise. A failed save keeps its existing error toast. -`liveTrackingArgs` (`:186`) needs **no change** — it already calls `resolveTargetEntity` and therefore inherits the new error for free. Its previous silent alphabetically-first pick was the same class of defect as silent fan-out; this fixes it as a side effect. +**`liveTrackingArgs` (`:186`)** inherits the new error for free — it already calls `resolveTargetEntity`, so its previous silent alphabetically-first pick is fixed as a side effect. But it does need a `catchError` of its own, because otherwise the error would be **invisible**. See "The `startLiveLocation` error sink" below. ### `location-target-entity.component.ts` / `.html` @@ -160,6 +160,7 @@ Alternatives considered and rejected: - `widget-action.location.error-alias-multiple` — *"Entity alias '{{alias}}' resolves to {{count}} entities. The location can only be saved to a single entity."* - `widget-action.mobile.target-alias-multiple-warning` — *"This alias can resolve to multiple entities. Saving will fail unless it resolves to exactly one."* - `widget-action.mobile.target-panel-title` — *"Target"* +- `widget-action.mobile.live-location-start-failed` — *"Failed to start live location tracking: {{error}}"*. A new key rather than reusing `location-save-failed` ("Failed to save location: …"), which would misdescribe a start failure. **Rename:** `widget-action.mobile.target-entity-type` → `widget-action.mobile.target-save-to`, new value *"Save to"*. Its three template references (`location-target-entity.component.html:21`, `:23`, `:37`) collapse to one: `:21`/`:23` are the two `panelHint` branches that this design merges into a single unconditional title using the new `target-panel-title` key, leaving `:37` as the only `target-save-to` use. @@ -185,10 +186,19 @@ Alternatives considered and rejected: | `CURRENT_ENTITY` with no active entity | `error-no-current-entity` (existing) | error toast | | Attribute-source problems | existing `error-attribute-*` family | error toast | | Save request fails | `location-save-failed` (existing, per action) | error toast | +| Any of the above while starting live tracking | `live-location-start-failed` (**new**) wrapping the cause | error toast | -Wrapping is unchanged: `saveMobileActionLocation` catches into a toast and completes without emitting (`:115-119`); `saveBrowserLocation` reports through its `subscribe` error handler (`:172-180`). +`saveMobileActionLocation` catches into a toast and completes without emitting; `saveBrowserLocation` reports through its `subscribe` error handler. Both are unchanged. -For `startLiveLocation` the resolution error surfaces before the bridge call, so no session starts — which is the correct outcome and matches how other `liveTrackingArgs` failures already behave. +### The `startLiveLocation` error sink + +`startLiveLocation` is the only action that resolves its target **during** bridge-args building rather than after, and that phase's error sink discards everything. An uncaught error propagates to the `error` callback of `argsObservable.subscribe(...)` (`widget.component.ts:1496`), which wraps it as `` `Failed to get mobile action arguments: ${err.message}` `` and hands it to `handleWidgetMobileActionError` (`:1509`) — a method whose entire body is one `if (isNotEmptyTbFunction(mobileAction.handleErrorFunction))` with no `else`. With no custom *Handle error* function configured on the action, the message is **dropped**: no toast, no `console.error`, no session. The button appears dead. + +That silence predates this design — it already swallows `error-alias-not-found`, `error-alias-not-resolved`, `error-no-current-entity` and the whole `error-attribute-*` family for live tracking. But making over-resolution a hard error adds a failure mode that is easy to hit by accident, which would convert the old *silently wrong* behaviour (tracking the alphabetically first entity) into *silently nothing* — strictly worse for the user, and the opposite of this design's goal. + +The config-time warning does not cover it: that shows to whoever edits the dashboard, while the person tapping the button in the mobile app is often someone else. + +**Fix:** give `liveTrackingArgs` the same treatment the other two savers already have — a trailing `catchError` that shows `live-location-start-failed` via `ctx.showErrorToast` and returns `EMPTY`, so the observable completes without emitting, `next` never fires, and no bridge call is made. All three actions then surface one clean localized toast, and the four pre-existing silent conditions are closed as a side effect. ## Testing