From 35ddc8bba0eaea5f35a1b2d21fd90976df50b162 Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 10 Jun 2026 18:39:35 +0300 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 6/8] 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 7/8] 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 8/8] 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.