diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 92db044b..25723c32 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -2,10 +2,11 @@ xmlns:tools="http://schemas.android.com/tools"> + + tools:remove="android:maxSdkVersion" /> @@ -15,6 +16,10 @@ + + + + **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/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. 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. diff --git a/docs/superpowers/plans/2026-07-22-gps-live-tracking-ux.md b/docs/superpowers/plans/2026-07-22-gps-live-tracking-ux.md new file mode 100644 index 00000000..06cea8ae --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-gps-live-tracking-ux.md @@ -0,0 +1,1742 @@ +# GPS Live Tracking UX & Persistence (Phase 1d) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Polish the phase-1c live-tracking experience: friendly entity name, a bundle-gated "Live location tracking" page (replacing the debug spike) with a persisted last-session + "Start again" idle state, and a full-width pulsing collapsed bar. + +**Architecture:** The menu page is registered as a server-driven mobile-bundle page type (`thingsboard` repo: one backend enum value + one ui-ngx models file), hidden by default and mapped to a route app-side. The app persists one `LastTrackingRecord` via `TbStorage` (written at session start, updated at end), resolves the target's display name through the Dart client's entity-data query, and relaunches a stored config from the idle page. + +**Tech Stack:** Java (thingsboard backend), Angular 18 (ui-ngx), Flutter/Dart, Riverpod codegen, `built_value` Dart client, `flutter_hooks`, `TbStorage` (secure storage). + +## Global Constraints + +- Repos: `thingsboard` at `/home/artem/projects/thingsboard` branch `feat/gps-tracker` (backend + ui-ngx); Flutter at `/home/artem/projects/mobile/flutter_thingsboard_app` branch `feat/gps-tracker`. Leave the pre-existing uncommitted `ui-ngx/proxy.conf.js` change alone. +- Conventional Commits; **no** `Co-Authored-By` lines. +- Dart: `dart format` on changed files only; `flutter analyze` must stay clean for changed files; codegen via `flutter pub run build_runner build --delete-conflicting-outputs` after touching `@freezed`/`@riverpod`; l10n via `flutter pub run intl_utils:generate`. +- ui-ngx verification: `cd ui-ngx && npx tsc --noEmit -p src/tsconfig.app.json` — expect exit 0 (only pre-existing photoswipe errors, if any). No new ui-ngx test scaffolding (repo convention). +- Production Flutter UI strings are localized (`S.of(context).key`, keys added to `lib/l10n/intl_en.arb` only — other locales fall back to English). +- The page id string on the wire is `LIVE_LOCATION_TRACKING` (backend `DefaultPageId` / ui-ngx `MobileMenuPath`); the app's `Pages` enum value is `live_location_tracking` (its `.name.toUpperCase()` must equal `LIVE_LOCATION_TRACKING`, which `pagesFromString` relies on). +- Defaults locked in design: name resolved app-side via API with fallback `DEVICE · `; "Start again" re-derives `trackedBy` from the current logged-in user; the record is device-global and cleared on logout. Spec: `docs/superpowers/specs/2026-07-22-gps-live-tracking-ux-design.md`. + +--- + +### Task 1: thingsboard repo — register the `LIVE_LOCATION_TRACKING` bundle page (backend + ui-ngx) + +**Files:** +- Modify: `/home/artem/projects/thingsboard/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DefaultPageId.java` +- Modify: `/home/artem/projects/thingsboard/ui-ngx/src/app/shared/models/mobile-app.models.ts` (enum ~L96-107, `defaultMobileMenu` ~L169-179, `hideDefaultMenuItems` ~L181-184, `defaultMobilePageMap` ~L232-305) + +**Interfaces:** +- Produces: a default mobile page id `LIVE_LOCATION_TRACKING`, present in every bundle layout but `visible: false` by default (admin toggles it on in Mobile center → Bundles → Layout). Icon `my_location`, label `Live location tracking`. + +- [ ] **Step 1: Add the backend enum constant** + +In `DefaultPageId.java`, add `LIVE_LOCATION_TRACKING` after `DASHBOARDS`: + +```java +public enum DefaultPageId { + + HOME, + ALARMS, + DEVICES, + CUSTOMERS, + ASSETS, + AUDIT_LOGS, + NOTIFICATIONS, + DEVICE_LIST, + DASHBOARDS, + LIVE_LOCATION_TRACKING +} +``` + +- [ ] **Step 2: Add the ui-ngx enum member** + +In `mobile-app.models.ts`, add to the `MobileMenuPath` enum: + +```ts + NOTIFICATIONS = 'NOTIFICATIONS', + LIVE_LOCATION_TRACKING = 'LIVE_LOCATION_TRACKING' +``` + +- [ ] **Step 3: Seed it into the default menu, hidden by default** + +Append to `defaultMobileMenu`: + +```ts + MobileMenuPath.DASHBOARDS, + MobileMenuPath.LIVE_LOCATION_TRACKING +]; +``` + +Append to `hideDefaultMenuItems` (this makes it listed-but-off by default): + +```ts +export const hideDefaultMenuItems = [ + MobileMenuPath.DEVICE_LIST, + MobileMenuPath.DASHBOARDS, + MobileMenuPath.LIVE_LOCATION_TRACKING +]; +``` + +- [ ] **Step 4: Add the icon/label map entry** + +Add to `defaultMobilePageMap`: + +```ts + [ MobileMenuPath.LIVE_LOCATION_TRACKING, { id: MobileMenuPath.LIVE_LOCATION_TRACKING, icon: 'my_location', label: 'Live location tracking' } ] +``` + +- [ ] **Step 5: Verify ui-ngx compiles** + +Run: `cd /home/artem/projects/thingsboard/ui-ngx && npx tsc --noEmit -p src/tsconfig.app.json` +Expected: exit 0 (only any pre-existing photoswipe errors). + +- [ ] **Step 6: Commit** + +```bash +cd /home/artem/projects/thingsboard +git add common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DefaultPageId.java ui-ngx/src/app/shared/models/mobile-app.models.ts +git commit -m "feat(mobile): add Live location tracking as a hidden-by-default bundle page" +``` + +--- + +### Task 2: Flutter — `LiveTrackingConfig.toJson` (round-trip for persistence) + +**Files:** +- Modify: `lib/utils/services/live_location_tracking/model/live_tracking_config.dart` +- Test: `test/utils/services/live_location_tracking/live_tracking_config_test.dart` (existing — add cases) + +**Interfaces:** +- Produces: `LiveTrackingConfig.toJson() → Map` and `LiveTrackingTarget.toJson()`; `LiveTrackingConfig.fromJson(config.toJson())` reproduces an equal config. Consumed by Task 4 (record) and Task 7 ("Start again"). + +- [ ] **Step 1: Add the failing round-trip test** + +Append to `test/utils/services/live_location_tracking/live_tracking_config_test.dart` inside `main()`: + +```dart + test('toJson round-trips through fromJson', () { + const original = LiveTrackingConfig( + target: LiveTrackingTarget(entityType: 'DEVICE', id: 'abc-123'), + latitudeKey: 'lat', + longitudeKey: 'lng', + includeMetadata: true, + mirrorToAttributes: true, + accuracy: LocationAccuracyLevel.high, + distanceFilterMeters: 25, + intervalSeconds: 60, + maxDurationMinutes: 120, + writeStatusAttributes: false, + trackedBy: 'user@example.com', + ); + + final restored = LiveTrackingConfig.fromJson(original.toJson()); + + expect(restored.target.entityType, 'DEVICE'); + expect(restored.target.id, 'abc-123'); + expect(restored.latitudeKey, 'lat'); + expect(restored.longitudeKey, 'lng'); + expect(restored.includeMetadata, true); + expect(restored.mirrorToAttributes, true); + expect(restored.accuracy, LocationAccuracyLevel.high); + expect(restored.distanceFilterMeters, 25); + expect(restored.intervalSeconds, 60); + expect(restored.maxDurationMinutes, 120); + expect(restored.writeStatusAttributes, false); + expect(restored.trackedBy, 'user@example.com'); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/utils/services/live_location_tracking/live_tracking_config_test.dart` +Expected: FAIL — `toJson` not defined. + +- [ ] **Step 3: Implement `toJson`** + +In `LiveTrackingTarget`, add: + +```dart + Map toJson() => {'entityType': entityType, 'id': id}; +``` + +In `LiveTrackingConfig`, add (and a private `_accuracyToString`): + +```dart + Map toJson() => { + 'target': target.toJson(), + 'latitudeKey': latitudeKey, + 'longitudeKey': longitudeKey, + 'includeMetadata': includeMetadata, + 'mirrorToAttributes': mirrorToAttributes, + 'accuracy': _accuracyToString(accuracy), + 'distanceFilterMeters': distanceFilterMeters, + 'intervalSeconds': intervalSeconds, + 'maxDurationMinutes': maxDurationMinutes, + 'writeStatusAttributes': writeStatusAttributes, + 'trackedBy': trackedBy, + }; + + static String _accuracyToString(LocationAccuracyLevel level) => + switch (level) { + LocationAccuracyLevel.high => 'HIGH', + LocationAccuracyLevel.low => 'LOW', + LocationAccuracyLevel.balanced => 'BALANCED', + }; +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/utils/services/live_location_tracking/live_tracking_config_test.dart` +Expected: PASS (all cases). + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format lib/utils/services/live_location_tracking/model/live_tracking_config.dart test/utils/services/live_location_tracking/live_tracking_config_test.dart +flutter analyze 2>&1 | grep live_tracking_config ; echo "expect no output above" +git add lib/utils/services/live_location_tracking/model/live_tracking_config.dart test/utils/services/live_location_tracking/live_tracking_config_test.dart +git commit -m "feat(location): add LiveTrackingConfig.toJson for session persistence" +``` + +--- + +### Task 3: Flutter — `LastTrackingRecord` model + +**Files:** +- Create: `lib/utils/services/live_location_tracking/model/last_tracking_record.dart` +- Test: `test/utils/services/live_location_tracking/last_tracking_record_test.dart` + +**Interfaces:** +- Consumes: `LiveTrackingConfig` (Task 2). +- Produces: `enum TrackingEndReason { manual, maxDuration, interrupted }`; `LastTrackingRecord` (plain class, not freezed) with fields `configJson: Map`, `targetName: String?`, `startedAt: DateTime`, `endedAt: DateTime?`, `fixCount/savedCount/saveErrorCount: int`, `lastLat/lastLng: double?`, `lastError: String?`, `endReason: TrackingEndReason`; `toJson()`/`fromJson()`; `config` getter returning `LiveTrackingConfig.fromJson(configJson)`. Consumed by Tasks 4, 6, 7. + +- [ ] **Step 1: Write the failing test** + +Create `test/utils/services/live_location_tracking/last_tracking_record_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; + +void main() { + final json = { + 'configJson': { + 'target': {'entityType': 'DEVICE', 'id': 'd-1'}, + }, + 'targetName': 'My Tracker', + 'startedAt': 1720000000000, + 'endedAt': 1720000600000, + 'fixCount': 12, + 'savedCount': 11, + 'saveErrorCount': 1, + 'lastLat': 1.5, + 'lastLng': 2.5, + 'lastError': 'boom', + 'endReason': 'maxDuration', + }; + + test('fromJson/toJson round-trips', () { + final record = LastTrackingRecord.fromJson(json); + expect(record.targetName, 'My Tracker'); + expect(record.startedAt.millisecondsSinceEpoch, 1720000000000); + expect(record.endedAt?.millisecondsSinceEpoch, 1720000600000); + expect(record.fixCount, 12); + expect(record.savedCount, 11); + expect(record.saveErrorCount, 1); + expect(record.lastLat, 1.5); + expect(record.lastLng, 2.5); + expect(record.lastError, 'boom'); + expect(record.endReason, TrackingEndReason.maxDuration); + expect(record.config.target.id, 'd-1'); + expect(LastTrackingRecord.fromJson(record.toJson()).toJson(), record.toJson()); + }); + + test('unknown endReason falls back to interrupted', () { + final record = LastTrackingRecord.fromJson({ + ...json, + 'endReason': 'nonsense', + }); + expect(record.endReason, TrackingEndReason.interrupted); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/utils/services/live_location_tracking/last_tracking_record_test.dart` +Expected: FAIL — file/class don't exist. + +- [ ] **Step 3: Implement the model** + +Create `lib/utils/services/live_location_tracking/model/last_tracking_record.dart`: + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; + +enum TrackingEndReason { manual, maxDuration, interrupted } + +TrackingEndReason _endReasonFromString(String? value) => + TrackingEndReason.values.firstWhere( + (e) => e.name == value, + orElse: () => TrackingEndReason.interrupted, + ); + +/// Snapshot of the most recent tracking session, persisted so the idle page +/// can show it and relaunch it ("Start again"). Written at session start and +/// updated at session end. +class LastTrackingRecord { + const LastTrackingRecord({ + required this.configJson, + required this.startedAt, + required this.endReason, + this.targetName, + this.endedAt, + this.fixCount = 0, + this.savedCount = 0, + this.saveErrorCount = 0, + this.lastLat, + this.lastLng, + this.lastError, + }); + + factory LastTrackingRecord.fromJson(Map json) => + LastTrackingRecord( + configJson: Map.from(json['configJson'] as Map), + targetName: json['targetName'] as String?, + startedAt: DateTime.fromMillisecondsSinceEpoch( + (json['startedAt'] as num).toInt(), + ), + endedAt: + json['endedAt'] == null + ? null + : DateTime.fromMillisecondsSinceEpoch( + (json['endedAt'] as num).toInt(), + ), + fixCount: (json['fixCount'] as num?)?.toInt() ?? 0, + savedCount: (json['savedCount'] as num?)?.toInt() ?? 0, + saveErrorCount: (json['saveErrorCount'] as num?)?.toInt() ?? 0, + lastLat: (json['lastLat'] as num?)?.toDouble(), + lastLng: (json['lastLng'] as num?)?.toDouble(), + lastError: json['lastError'] as String?, + endReason: _endReasonFromString(json['endReason'] as String?), + ); + + final Map configJson; + final String? targetName; + final DateTime startedAt; + final DateTime? endedAt; + final int fixCount; + final int savedCount; + final int saveErrorCount; + final double? lastLat; + final double? lastLng; + final String? lastError; + final TrackingEndReason endReason; + + LiveTrackingConfig get config => LiveTrackingConfig.fromJson(configJson); + + Map toJson() => { + 'configJson': configJson, + 'targetName': targetName, + 'startedAt': startedAt.millisecondsSinceEpoch, + 'endedAt': endedAt?.millisecondsSinceEpoch, + 'fixCount': fixCount, + 'savedCount': savedCount, + 'saveErrorCount': saveErrorCount, + 'lastLat': lastLat, + 'lastLng': lastLng, + 'lastError': lastError, + 'endReason': endReason.name, + }; + + LastTrackingRecord copyWith({ + String? targetName, + DateTime? endedAt, + int? fixCount, + int? savedCount, + int? saveErrorCount, + double? lastLat, + double? lastLng, + String? lastError, + TrackingEndReason? endReason, + }) => LastTrackingRecord( + configJson: configJson, + targetName: targetName ?? this.targetName, + startedAt: startedAt, + endedAt: endedAt ?? this.endedAt, + fixCount: fixCount ?? this.fixCount, + savedCount: savedCount ?? this.savedCount, + saveErrorCount: saveErrorCount ?? this.saveErrorCount, + lastLat: lastLat ?? this.lastLat, + lastLng: lastLng ?? this.lastLng, + lastError: lastError ?? this.lastError, + endReason: endReason ?? this.endReason, + ); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/utils/services/live_location_tracking/last_tracking_record_test.dart` +Expected: PASS (2 tests). + +- [ ] **Step 5: Format, analyze, commit** + +```bash +dart format lib/utils/services/live_location_tracking/model/last_tracking_record.dart test/utils/services/live_location_tracking/last_tracking_record_test.dart +flutter analyze 2>&1 | grep last_tracking_record ; echo "expect no output above" +git add lib/utils/services/live_location_tracking/model/last_tracking_record.dart test/utils/services/live_location_tracking/last_tracking_record_test.dart +git commit -m "feat(location): add LastTrackingRecord model" +``` + +--- + +### Task 4: Flutter — `ILiveTrackingStore` (TbStorage-backed) + DI + +**Files:** +- Create: `lib/utils/services/live_location_tracking/i_live_tracking_store.dart` +- Create: `lib/utils/services/live_location_tracking/live_tracking_store.dart` +- Modify: `lib/constants/database_keys.dart` (add key) +- Modify: `lib/locator.dart` (register after the `ILiveLocationTrackingService` block, ~L73) +- Test: `test/utils/services/live_location_tracking/live_tracking_store_test.dart` + +**Interfaces:** +- Consumes: `LastTrackingRecord` (Task 3), `TbStorage` (`getItem`/`setItem`/`deleteItem`), `TbLogger`. +- Produces: `ILiveTrackingStore { Future read(); Future write(LastTrackingRecord record); Future clear(); }`; GetIt registration `getIt()`. Consumed by Tasks 6, 7, 10. + +- [ ] **Step 1: Write the failing test** + +Create `test/utils/services/live_location_tracking/live_tracking_store_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/thingsboard_client.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; + +class FakeStorage implements TbStorage { + final _map = {}; + + @override + Future getItem(String key) async => _map[key]; + + @override + Future setItem(String key, dynamic value) async => _map[key] = value; + + @override + Future deleteItem(String key) async => _map.remove(key); +} + +void main() { + late FakeStorage storage; + late LiveTrackingStore store; + + setUp(() { + storage = FakeStorage(); + store = LiveTrackingStore(storage: storage, logger: TbLogger()); + }); + + final record = LastTrackingRecord( + configJson: const { + 'target': {'entityType': 'DEVICE', 'id': 'd-1'}, + }, + startedAt: DateTime.fromMillisecondsSinceEpoch(1720000000000), + endReason: TrackingEndReason.interrupted, + targetName: 'My Tracker', + ); + + test('read returns null when nothing stored', () async { + expect(await store.read(), isNull); + }); + + test('write then read round-trips', () async { + await store.write(record); + final read = await store.read(); + expect(read?.targetName, 'My Tracker'); + expect(read?.config.target.id, 'd-1'); + }); + + test('clear removes the record', () async { + await store.write(record); + await store.clear(); + expect(await store.read(), isNull); + }); + + test('read returns null on corrupt json', () async { + await storage.setItem('live_tracking_last_record', '{not valid'); + expect(await store.read(), isNull); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/utils/services/live_location_tracking/live_tracking_store_test.dart` +Expected: FAIL — `LiveTrackingStore` doesn't exist. + +- [ ] **Step 3: Add the storage key** + +In `lib/constants/database_keys.dart`, add a constant alongside the existing keys: + +```dart + static const liveTrackingLastRecord = 'live_tracking_last_record'; +``` + +- [ ] **Step 4: Create the interface** + +Create `lib/utils/services/live_location_tracking/i_live_tracking_store.dart`: + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; + +/// Persists the single most-recent tracking session so the idle page can show +/// and relaunch it. Device-global; cleared on logout. +abstract interface class ILiveTrackingStore { + Future read(); + + Future write(LastTrackingRecord record); + + Future clear(); +} +``` + +- [ ] **Step 5: Implement the store** + +Create `lib/utils/services/live_location_tracking/live_tracking_store.dart`: + +```dart +import 'dart:convert'; + +import 'package:thingsboard_app/constants/database_keys.dart'; +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/thingsboard_client.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; + +class LiveTrackingStore implements ILiveTrackingStore { + LiveTrackingStore({required TbStorage storage, required TbLogger logger}) + : _storage = storage, + _log = logger; + + final TbStorage _storage; + final TbLogger _log; + + @override + Future read() async { + try { + final raw = await _storage.getItem(DatabaseKeys.liveTrackingLastRecord); + if (raw is! String) { + return null; + } + return LastTrackingRecord.fromJson( + Map.from(jsonDecode(raw) as Map), + ); + } catch (e, s) { + _log.error('LiveTrackingStore.read failed', e, s); + return null; + } + } + + @override + Future write(LastTrackingRecord record) async { + try { + await _storage.setItem( + DatabaseKeys.liveTrackingLastRecord, + jsonEncode(record.toJson()), + ); + } catch (e, s) { + _log.error('LiveTrackingStore.write failed', e, s); + } + } + + @override + Future clear() async { + try { + await _storage.deleteItem(DatabaseKeys.liveTrackingLastRecord); + } catch (e, s) { + _log.error('LiveTrackingStore.clear failed', e, s); + } + } +} +``` + +- [ ] **Step 6: Register in GetIt** + +In `lib/locator.dart`, add imports and register after the `ILiveLocationTrackingService` registration: + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_store.dart'; +``` + +```dart + ..registerLazySingleton( + () => LiveTrackingStore(storage: getIt(), logger: getIt()), + ) +``` + +- [ ] **Step 7: Run test, analyze, commit** + +```bash +flutter test test/utils/services/live_location_tracking/live_tracking_store_test.dart +flutter analyze 2>&1 | grep -E "live_tracking_store|database_keys|locator" ; echo "expect no output above" +dart format lib/utils/services/live_location_tracking/i_live_tracking_store.dart lib/utils/services/live_location_tracking/live_tracking_store.dart lib/constants/database_keys.dart lib/locator.dart test/utils/services/live_location_tracking/live_tracking_store_test.dart +git add lib/utils/services/live_location_tracking/ lib/constants/database_keys.dart lib/locator.dart test/utils/services/live_location_tracking/live_tracking_store_test.dart +git commit -m "feat(location): add ILiveTrackingStore for last-session persistence" +``` + +--- + +### Task 5: Flutter — entity name resolver + display-name helper + +**Files:** +- Modify: `lib/utils/services/entity_query_api.dart` (add `createEntityNameQuery`) +- Create: `lib/utils/services/live_location_tracking/i_entity_name_resolver.dart` +- Create: `lib/utils/services/live_location_tracking/entity_name_resolver.dart` +- Create: `lib/utils/services/live_location_tracking/live_tracking_display.dart` (pure fallback helper) +- Modify: `lib/locator.dart` +- Test: `test/utils/services/live_location_tracking/live_tracking_display_test.dart` + +**Interfaces:** +- Consumes: `ITbClientService.client.getEntityQueryControllerApi().findEntityDataByQuery`, the `EntityDataHelpers.field` extension (`entity_query_api.dart`), `LiveTrackingTarget`. +- Produces: `IEntityNameResolver { Future resolveName(String entityType, String id); }` (registered in GetIt); `displayTargetName(String? resolved, LiveTrackingTarget target) → String` returning `resolved` when non-empty else `'${target.entityType} · ${shortId}'`. Consumed by Tasks 6, 7. + +- [ ] **Step 1: Write the failing test (pure helper)** + +Create `test/utils/services/live_location_tracking/live_tracking_display_test.dart`: + +```dart +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_display.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; + +void main() { + const target = LiveTrackingTarget( + entityType: 'DEVICE', + id: 'f3eda640-42e8-11f1-af6c-63e319b36637', + ); + + test('uses resolved name when present', () { + expect(displayTargetName('My Tracker', target), 'My Tracker'); + }); + + test('falls back to type and short id when name is null', () { + expect(displayTargetName(null, target), 'DEVICE · f3eda640'); + }); + + test('falls back when name is empty/whitespace', () { + expect(displayTargetName(' ', target), 'DEVICE · f3eda640'); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/utils/services/live_location_tracking/live_tracking_display_test.dart` +Expected: FAIL — `displayTargetName` not defined. + +- [ ] **Step 3: Implement the pure helper** + +Create `lib/utils/services/live_location_tracking/live_tracking_display.dart`: + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; + +/// Human-friendly label for a tracking target: the resolved entity name when +/// available, otherwise ` · `. +String displayTargetName(String? resolved, LiveTrackingTarget target) { + if (resolved != null && resolved.trim().isNotEmpty) { + return resolved; + } + final shortId = + target.id.length > 8 ? target.id.substring(0, 8) : target.id; + return '${target.entityType} · $shortId'; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/utils/services/live_location_tracking/live_tracking_display_test.dart` +Expected: PASS (3 tests). + +- [ ] **Step 5: Add the name query builder** + +In `lib/utils/services/entity_query_api.dart`, add a static method to `EntityQueryApi` (mirrors the existing `SingleEntityFilter`/`AliasEntityId` built_value pattern; `EntityType.valueOf` maps the wire string): + +```dart + static EntityDataQuery createEntityNameQuery(String entityType, String id) { + return EntityDataQuery( + (b) => + b + ..entityFilter = SingleEntityFilter( + (b) => + b + ..type = 'singleEntity' + ..singleEntity = + AliasEntityId( + (b) => + b + ..entityType = EntityType.valueOf(entityType) + ..id = id, + ).toBuilder(), + ) + ..entityFields = + BuiltList([ + EntityKey( + (b) => + b + ..type = EntityKeyType.ENTITY_FIELD + ..key = 'name', + ), + ]).toBuilder() + ..pageLink = + EntityDataPageLink((b) => b..pageSize = 1).toBuilder(), + ); + } +``` + +- [ ] **Step 6: Create the resolver interface + impl** + +Create `lib/utils/services/live_location_tracking/i_entity_name_resolver.dart`: + +```dart +/// Resolves an entity's display name from its type + id. Returns null on any +/// failure (offline, deleted entity, unknown type) so callers fall back. +abstract interface class IEntityNameResolver { + Future resolveName(String entityType, String id); +} +``` + +Create `lib/utils/services/live_location_tracking/entity_name_resolver.dart`: + +```dart +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/utils/services/entity_query_api.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; + +class EntityNameResolver implements IEntityNameResolver { + EntityNameResolver({required ITbClientService clientService, required TbLogger logger}) + : _clientService = clientService, + _log = logger; + + final ITbClientService _clientService; + final TbLogger _log; + + @override + Future resolveName(String entityType, String id) async { + try { + final query = EntityQueryApi.createEntityNameQuery(entityType, id); + final response = await _clientService.client + .getEntityQueryControllerApi() + .findEntityDataByQuery(entityDataQuery: query); + final data = response.data?.data; + if (data == null || data.isEmpty) { + return null; + } + return data.first.field('name'); + } catch (e, s) { + _log.error('EntityNameResolver.resolveName failed', e, s); + return null; + } + } +} +``` + +- [ ] **Step 7: Register in GetIt** + +In `lib/locator.dart` add imports and register (near the store): + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/entity_name_resolver.dart'; +``` + +```dart + ..registerLazySingleton( + () => EntityNameResolver(clientService: getIt(), logger: getIt()), + ) +``` + +- [ ] **Step 8: Analyze, format, commit** + +```bash +flutter analyze 2>&1 | grep -E "entity_name_resolver|entity_query_api|live_tracking_display|locator" ; echo "expect no output above" +dart format lib/utils/services/entity_query_api.dart lib/utils/services/live_location_tracking/i_entity_name_resolver.dart lib/utils/services/live_location_tracking/entity_name_resolver.dart lib/utils/services/live_location_tracking/live_tracking_display.dart lib/locator.dart test/utils/services/live_location_tracking/live_tracking_display_test.dart +git add lib/utils/services/entity_query_api.dart lib/utils/services/live_location_tracking/ lib/locator.dart test/utils/services/live_location_tracking/live_tracking_display_test.dart +git commit -m "feat(location): add entity name resolver and display-name fallback" +``` + +> Note: `EntityNameResolver` itself is not unit-tested (it wraps the built_value client, which the repo does not mock); its failure path returns null and is covered by the `displayTargetName` fallback tests. Verify the query at runtime in Task 11's smoke test. + +--- + +### Task 6: Flutter — wire store + name resolver + end reasons into the tracking service + +**Files:** +- Modify: `lib/utils/services/live_location_tracking/live_location_tracking_service.dart` +- Modify: `lib/locator.dart` (pass new deps into `LiveLocationTrackingService`) +- Test: `test/utils/services/live_location_tracking/live_location_tracking_service_test.dart` (existing — extend fakes + add cases) + +**Interfaces:** +- Consumes: `ILiveTrackingStore` (Task 4), `IEntityNameResolver` (Task 5), `LastTrackingRecord`/`TrackingEndReason` (Task 3). +- Produces: on `start`, writes a `LastTrackingRecord` (endReason `interrupted`, resolved `targetName`); on end via `stop` writes `endReason: manual`, via max-duration writes `endReason: maxDuration`, both with final counts + last coordinates + `lastError`. No interface signature change to `ILiveLocationTrackingService`. + +- [ ] **Step 1: Extend the test fakes and add cases** + +In `test/utils/services/live_location_tracking/live_location_tracking_service_test.dart`, add fakes near the top: + +```dart +class FakeStore implements ILiveTrackingStore { + LastTrackingRecord? record; + int writeCount = 0; + + @override + Future read() async => record; + + @override + Future write(LastTrackingRecord r) async { + record = r; + writeCount++; + } + + @override + Future clear() async => record = null; +} + +class FakeNameResolver implements IEntityNameResolver { + String? name = 'My Tracker'; + + @override + Future resolveName(String entityType, String id) async => name; +} +``` + +Add imports: + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; +``` + +Update `setUp` to construct the service with the new deps: + +```dart + late FakeStore store; + late FakeNameResolver nameResolver; + + setUp(() { + location = FakeLocationService(); + remote = FakeRemote(); + store = FakeStore(); + nameResolver = FakeNameResolver(); + service = LiveLocationTrackingService( + locationService: location, + remote: remote, + logger: TbLogger(), + store: store, + nameResolver: nameResolver, + ); + }); +``` + +Add cases: + +```dart + test('start writes an interrupted record with the resolved name', () async { + await service.start(const LiveTrackingConfig(target: target)); + expect(store.record, isNotNull); + expect(store.record!.targetName, 'My Tracker'); + expect(store.record!.endReason, TrackingEndReason.interrupted); + expect(store.record!.endedAt, isNull); + }); + + test('stop updates the record with manual end reason and counts', () async { + await service.start(const LiveTrackingConfig(target: target)); + location.controller!.add(LocationSuccess(fix)); + await pumpEventQueue(); + + await service.stop(); + expect(store.record!.endReason, TrackingEndReason.manual); + expect(store.record!.endedAt, isNotNull); + expect(store.record!.fixCount, 1); + expect(store.record!.savedCount, 1); + expect(store.record!.lastLat, 1.0); + expect(store.record!.lastLng, 2.0); + }); + + test('max-duration end writes maxDuration reason', () { + fakeAsync((async) { + service.start( + const LiveTrackingConfig(target: target, maxDurationMinutes: 5), + ); + async.flushMicrotasks(); + async.elapse(const Duration(minutes: 5, seconds: 1)); + async.flushMicrotasks(); + expect(store.record!.endReason, TrackingEndReason.maxDuration); + }); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/utils/services/live_location_tracking/live_location_tracking_service_test.dart` +Expected: FAIL — constructor has no `store`/`nameResolver`. + +- [ ] **Step 3: Implement the service changes** + +In `live_location_tracking_service.dart`: + +Add imports: + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; +``` + +Extend the constructor and fields: + +```dart + LiveLocationTrackingService({ + required ILocationService locationService, + required ILiveTrackingRemote remote, + required TbLogger logger, + required ILiveTrackingStore store, + required IEntityNameResolver nameResolver, + this.backgroundConfig = const BackgroundTrackingConfig( + notificationTitle: 'ThingsBoard', + notificationText: 'Live location tracking is active', + ), + }) : _locationService = locationService, + _remote = remote, + _log = logger, + _store = store, + _nameResolver = nameResolver; + + final ILocationService _locationService; + final ILiveTrackingRemote _remote; + final TbLogger _log; + final ILiveTrackingStore _store; + final IEntityNameResolver _nameResolver; +``` + +Replace `start` so it seeds a record (endReason interrupted) and resolves the name: + +```dart + @override + Future start(LiveTrackingConfig config) async { + await stop(); + final startedAt = DateTime.now(); + _setSession( + LiveTrackingSession( + config: config, + status: LiveTrackingStatus.tracking, + startedAt: startedAt, + ), + ); + final name = await _nameResolver.resolveName( + config.target.entityType, + config.target.id, + ); + await _store.write( + LastTrackingRecord( + configJson: config.toJson(), + targetName: name, + startedAt: startedAt, + endReason: TrackingEndReason.interrupted, + ), + ); + await _writeStatusAttributes({ + 'gpsActive': true, + if (config.trackedBy != null) 'gpsTrackedBy': config.trackedBy, + }); + _subscribe(config); + final maxDuration = config.maxDurationMinutes; + if (maxDuration != null) { + _maxDurationTimer = Timer( + Duration(minutes: maxDuration), + () => _finish(TrackingEndReason.maxDuration), + ); + } + } +``` + +Replace `stop` to delegate to a private `_finish(manual)`, and add `_finish`: + +```dart + @override + Future stop() => _finish(TrackingEndReason.manual); + + Future _finish(TrackingEndReason reason) async { + _maxDurationTimer?.cancel(); + _maxDurationTimer = null; + _cancelSubscription(); + final current = _session; + if (current != null) { + await _writeStatusAttributes({'gpsActive': false}); + await _updateRecordOnEnd(current, reason); + _setSession(null); + } + } + + Future _updateRecordOnEnd( + LiveTrackingSession session, + TrackingEndReason reason, + ) async { + final existing = await _store.read(); + if (existing == null) { + return; + } + await _store.write( + existing.copyWith( + endedAt: DateTime.now(), + fixCount: session.fixCount, + savedCount: session.savedCount, + saveErrorCount: session.saveErrorCount, + lastLat: session.lastFix?.latitude, + lastLng: session.lastFix?.longitude, + lastError: session.lastError, + endReason: reason, + ), + ); + } +``` + +(Leave `pause`, `resume`, `_onFix`, `_saveFix`, `_pauseWithError`, `_subscribe`, `_writeStatusAttributes`, `_setSession`, `_cancelSubscription` unchanged.) + +- [ ] **Step 4: Update the locator wiring** + +In `lib/locator.dart`, update the `ILiveLocationTrackingService` registration to pass the new deps: + +```dart + ..registerLazySingleton( + () => LiveLocationTrackingService( + locationService: getIt(), + remote: getIt(), + logger: getIt(), + store: getIt(), + nameResolver: getIt(), + ), + ) +``` + +(Ensure the `ILiveTrackingStore` and `IEntityNameResolver` registrations from Tasks 4–5 appear **before** this one.) + +- [ ] **Step 5: Run tests, analyze, commit** + +```bash +flutter test test/utils/services/live_location_tracking/ +flutter analyze 2>&1 | grep -E "live_location_tracking_service|locator" ; echo "expect no output above" +dart format lib/utils/services/live_location_tracking/live_location_tracking_service.dart lib/locator.dart test/utils/services/live_location_tracking/live_location_tracking_service_test.dart +git add lib/utils/services/live_location_tracking/live_location_tracking_service.dart lib/locator.dart test/utils/services/live_location_tracking/live_location_tracking_service_test.dart +git commit -m "feat(location): persist last session with end reason and resolved name" +``` + +--- + +### Task 7: Flutter — name provider, `LiveTrackingPage` (active + idle + Start again), l10n + +**Files:** +- Modify: `lib/l10n/intl_en.arb` (append keys) +- Modify: `lib/modules/location_tracking/presentation/provider/live_tracking_provider.dart` (add name + record providers) +- Create: `lib/modules/location_tracking/presentation/view/live_tracking_page.dart` +- Delete: `lib/modules/location_tracking/presentation/view/live_tracking_session_page.dart` (superseded) +- Test: `test/modules/location_tracking/live_tracking_page_test.dart` + +**Interfaces:** +- Consumes: `liveTrackingProvider` (session), `ILiveTrackingStore` + `IEntityNameResolver` via `getIt`, `displayTargetName` (Task 5), `LiveTrackingConfig.fromJson` (Task 2), `LastTrackingRecord` (Task 3), current-user email. +- Produces: `LiveTrackingPage` widget; `targetNameProvider` (`FutureProvider.family`); `lastRecordProvider` (`FutureProvider`). Consumed by Task 8 (route). + +- [ ] **Step 1: Add l10n keys** + +In `lib/l10n/intl_en.arb`, change the current last entry `"liveTrackingLastError": "Last error"` to have a trailing comma and append before the closing `}`: + +```json + "liveTrackingLastError": "Last error", + "liveTrackingMenuTitle": "Live location tracking", + "liveTrackingNoRecord": "No active tracking and no recent session.", + "liveTrackingLastSession": "Last session", + "liveTrackingStartAgain": "Start again", + "liveTrackingEnded": "Ended", + "liveTrackingEndReason": "End reason", + "liveTrackingEndReasonManual": "Stopped manually", + "liveTrackingEndReasonMaxDuration": "Reached max duration", + "liveTrackingEndReasonInterrupted": "Interrupted" +``` + +Run: `flutter pub run intl_utils:generate` +Expected: `lib/generated/l10n.dart` gains the new getters. + +- [ ] **Step 2: Add providers** + +In `lib/modules/location_tracking/presentation/provider/live_tracking_provider.dart`, add imports: + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; +``` + +Add two providers at the bottom of the file: + +```dart +@riverpod +Future targetName( + Ref ref, { + required String entityType, + required String id, +}) => getIt().resolveName(entityType, id); + +@riverpod +Future lastRecord(Ref ref) => + getIt().read(); +``` + +Run: `flutter pub run build_runner build --delete-conflicting-outputs` +Expected: regenerates `live_tracking_provider.g.dart` with `targetNameProvider` and `lastRecordProvider`. + +- [ ] **Step 3: Write the failing widget test** + +Create `test/modules/location_tracking/live_tracking_page_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:thingsboard_app/generated/l10n.dart'; +import 'package:thingsboard_app/modules/location_tracking/presentation/view/live_tracking_page.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; + +class _FakeStore implements ILiveTrackingStore { + _FakeStore(this.record); + final LastTrackingRecord? record; + @override + Future read() async => record; + @override + Future write(LastTrackingRecord r) async {} + @override + Future clear() async {} +} + +class _FakeResolver implements IEntityNameResolver { + @override + Future resolveName(String entityType, String id) async => null; +} + +Widget _wrap() => const ProviderScope( + child: MaterialApp( + localizationsDelegates: [S.delegate], + home: LiveTrackingPage(), + ), +); + +void main() { + tearDown(() => GetIt.I.reset()); + + testWidgets('idle with no record shows the empty message', (tester) async { + GetIt.I.registerLazySingleton(() => _FakeStore(null)); + GetIt.I.registerLazySingleton(() => _FakeResolver()); + + await tester.pumpWidget(_wrap()); + await tester.pumpAndSettle(); + + expect(find.text('No active tracking and no recent session.'), findsOneWidget); + }); + + testWidgets('idle with a record shows Start again', (tester) async { + final record = LastTrackingRecord( + configJson: const { + 'target': {'entityType': 'DEVICE', 'id': 'd-1'}, + }, + startedAt: DateTime.fromMillisecondsSinceEpoch(0), + endedAt: DateTime.fromMillisecondsSinceEpoch(60000), + endReason: TrackingEndReason.manual, + targetName: 'My Tracker', + ); + GetIt.I.registerLazySingleton(() => _FakeStore(record)); + GetIt.I.registerLazySingleton(() => _FakeResolver()); + + await tester.pumpWidget(_wrap()); + await tester.pumpAndSettle(); + + expect(find.text('Start again'), findsOneWidget); + expect(find.text('My Tracker'), findsWidgets); + }); +} +``` + +- [ ] **Step 4: Run test to verify it fails** + +Run: `flutter test test/modules/location_tracking/live_tracking_page_test.dart` +Expected: FAIL — `LiveTrackingPage` doesn't exist. + +- [ ] **Step 5: Implement `LiveTrackingPage`** + +Create `lib/modules/location_tracking/presentation/view/live_tracking_page.dart`. It renders one of three states. (The active-session details reuse the phase-1c layout; the Target row now uses `displayTargetName` fed by `targetNameProvider`.) + +```dart +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:thingsboard_app/generated/l10n.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/modules/location_tracking/presentation/provider/live_tracking_provider.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_display.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; +import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; + +class LiveTrackingPage extends ConsumerWidget { + const LiveTrackingPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final session = ref.watch(liveTrackingProvider).session; + return Scaffold( + appBar: AppBar(title: Text(S.of(context).liveTrackingSessionTitle)), + body: + session != null + ? _ActiveSession(session: session) + : _IdleView(), + ); + } +} + +class _ActiveSession extends ConsumerWidget { + const _ActiveSession({required this.session}); + + final LiveTrackingSession session; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tracking = session.status == LiveTrackingStatus.tracking; + final lastFix = session.lastFix; + final target = session.config.target; + final nameAsync = ref.watch( + targetNameProvider(entityType: target.entityType, id: target.id), + ); + final name = displayTargetName(nameAsync.valueOrNull, target); + return ListView( + children: [ + ListTile( + title: Text(S.of(context).liveTrackingTarget), + subtitle: Text(name), + ), + ListTile( + title: Text(S.of(context).liveTrackingStatus), + subtitle: Text( + tracking + ? S.of(context).liveTrackingActive + : S.of(context).liveTrackingPaused, + ), + ), + ListTile( + title: Text(S.of(context).liveTrackingStarted), + subtitle: Text(session.startedAt.toLocal().toString()), + ), + ListTile( + title: Text( + '${S.of(context).liveTrackingFixes}: ${session.fixCount} · ' + '${S.of(context).liveTrackingSaved}: ${session.savedCount} · ' + '${S.of(context).liveTrackingErrors}: ${session.saveErrorCount}', + ), + ), + if (lastFix != null) + ListTile( + title: Text(S.of(context).liveTrackingLastFix), + subtitle: Text( + '${lastFix.latitude.toStringAsFixed(6)}, ' + '${lastFix.longitude.toStringAsFixed(6)} ' + '(±${lastFix.accuracy.toStringAsFixed(0)} m)', + ), + ), + if (session.lastError != null) + ListTile( + title: Text(S.of(context).liveTrackingLastError), + subtitle: Text( + session.lastError!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () { + final notifier = ref.read(liveTrackingProvider.notifier); + tracking ? notifier.pause() : notifier.resume(); + }, + icon: Icon(tracking ? Icons.pause : Icons.play_arrow), + label: Text( + tracking + ? S.of(context).liveTrackingPause + : S.of(context).liveTrackingResume, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.icon( + onPressed: + () => ref.read(liveTrackingProvider.notifier).stop(), + icon: const Icon(Icons.stop), + label: Text(S.of(context).liveTrackingStop), + ), + ), + ], + ), + ), + ], + ); + } +} + +class _IdleView extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final recordAsync = ref.watch(lastRecordProvider); + return recordAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, __) => Center(child: Text(S.of(context).liveTrackingNoRecord)), + data: (record) { + if (record == null) { + return Center(child: Text(S.of(context).liveTrackingNoRecord)); + } + return _LastSession(record: record); + }, + ); + } +} + +class _LastSession extends ConsumerWidget { + const _LastSession({required this.record}); + + final LastTrackingRecord record; + + String _endReasonLabel(BuildContext context) => switch (record.endReason) { + TrackingEndReason.manual => S.of(context).liveTrackingEndReasonManual, + TrackingEndReason.maxDuration => + S.of(context).liveTrackingEndReasonMaxDuration, + TrackingEndReason.interrupted => + S.of(context).liveTrackingEndReasonInterrupted, + }; + + Future _startAgain(WidgetRef ref) async { + // AuthUser.sub is the current user's email (phase-1c trackedBy semantics); + // re-derive it so a relaunch is attributed to whoever is logged in now. + final email = getIt().client.getAuthUser()?.sub; + final config = LiveTrackingConfig.fromJson({ + ...record.configJson, + if (email != null) 'trackedBy': email, + }); + await ref.read(liveTrackingProvider.notifier).startConfig(config); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final target = record.config.target; + final name = displayTargetName(record.targetName, target); + return ListView( + children: [ + ListTile( + title: Text(S.of(context).liveTrackingLastSession), + subtitle: Text(name), + ), + ListTile( + title: Text(S.of(context).liveTrackingStarted), + subtitle: Text(record.startedAt.toLocal().toString()), + ), + if (record.endedAt != null) + ListTile( + title: Text(S.of(context).liveTrackingEnded), + subtitle: Text(record.endedAt!.toLocal().toString()), + ), + ListTile( + title: Text(S.of(context).liveTrackingEndReason), + subtitle: Text(_endReasonLabel(context)), + ), + ListTile( + title: Text( + '${S.of(context).liveTrackingFixes}: ${record.fixCount} · ' + '${S.of(context).liveTrackingSaved}: ${record.savedCount} · ' + '${S.of(context).liveTrackingErrors}: ${record.saveErrorCount}', + ), + ), + if (record.lastLat != null && record.lastLng != null) + ListTile( + title: Text(S.of(context).liveTrackingLastFix), + subtitle: Text( + '${record.lastLat!.toStringAsFixed(6)}, ' + '${record.lastLng!.toStringAsFixed(6)}', + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: FilledButton.icon( + onPressed: () => _startAgain(ref), + icon: const Icon(Icons.play_arrow), + label: Text(S.of(context).liveTrackingStartAgain), + ), + ), + ], + ); + } +} +``` + +- [ ] **Step 6: Add `startConfig` to the provider notifier** + +In `live_tracking_provider.dart`, add a method to the `LiveTracking` class (used by "Start again"): + +```dart + Future startConfig(LiveTrackingConfig config) => + getIt().start(config); +``` + +Add import: + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +``` + +- [ ] **Step 7: Delete the old session page** + +```bash +git rm lib/modules/location_tracking/presentation/view/live_tracking_session_page.dart +``` + +(Task 8 updates the route + bar that referenced it.) + +- [ ] **Step 8: Run test, regen, analyze, commit** + +```bash +flutter pub run build_runner build --delete-conflicting-outputs +flutter test test/modules/location_tracking/live_tracking_page_test.dart +flutter analyze 2>&1 | grep -E "live_tracking_page|live_tracking_provider" ; echo "expect no output above" +dart format lib/modules/location_tracking/ test/modules/location_tracking/live_tracking_page_test.dart lib/l10n +git add lib/modules/location_tracking/ lib/l10n/intl_en.arb lib/generated/ test/modules/location_tracking/live_tracking_page_test.dart +git commit -m "feat(location): add Live tracking page with idle last-session and Start again" +``` + +> `trackedBy` uses `getAuthUser()?.sub` — confirmed as the current user's email in the Dart client's `AuthUser` (`user_models.dart`: `String sub;`, `String? userId;`). The value is only a label. + +--- + +### Task 8: Flutter — page mapping, route rename, spike removal, bar route update + +**Files:** +- Modify: `lib/utils/services/layouts/pages_layout.dart` (add enum value) +- Modify: `lib/modules/main/providers/navigation_helper.dart` (4 switches + localized title) +- Modify: `lib/config/routes/v2/routes_config/routes/location_tracking_routes.dart` (rename route, drop spike, point to new page) +- Modify: `lib/modules/more/more_page.dart` (remove `kDebugMode` spike entry + import) +- Modify: `lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart` (update `context.push`) +- Delete: `lib/modules/location_tracking/presentation/view/live_tracking_spike_page.dart` +- Test: `test/modules/location_tracking/navigation_helper_live_tracking_test.dart` + +**Interfaces:** +- Consumes: `Pages` enum; `PageLayout`. +- Produces: `Pages.live_location_tracking`; `NavigationHelper.getPath(...) == '/liveTracking'`, `getLabel(...) == 'Live location tracking'`, `getIcon(...) == Icons.my_location`; route constant `LocationTrackingRoutes.liveTracking = '/liveTracking'` builds `LiveTrackingPage`. + +- [ ] **Step 1: Write the failing test** + +Create `test/modules/location_tracking/navigation_helper_live_tracking_test.dart`: + +```dart +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/modules/main/providers/navigation_helper.dart'; +import 'package:thingsboard_app/utils/services/layouts/pages_layout.dart'; + +void main() { + const layout = PageLayout(id: Pages.live_location_tracking); + + test('parses LIVE_LOCATION_TRACKING from server string', () { + expect(pagesFromString('LIVE_LOCATION_TRACKING'), Pages.live_location_tracking); + }); + + test('maps to route, label and icon', () { + expect(NavigationHelper.getPath(layout), '/liveTracking'); + expect(NavigationHelper.getLabel(layout), 'Live location tracking'); + expect(NavigationHelper.getIcon(layout), Icons.my_location); + }); +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/modules/location_tracking/navigation_helper_live_tracking_test.dart` +Expected: FAIL — `Pages.live_location_tracking` doesn't exist. + +- [ ] **Step 3: Add the enum value** + +In `lib/utils/services/layouts/pages_layout.dart`, add to `Pages` before `undefined`: + +```dart + dashboards, + live_location_tracking, + undefined, +``` + +- [ ] **Step 4: Map it in NavigationHelper** + +In `lib/modules/main/providers/navigation_helper.dart`: + +`getLabel` — add before the `undefined`/`null` case: + +```dart + case Pages.live_location_tracking: + return 'Live location tracking'; +``` + +`getIcon` — add before the `undefined`/`null` case: + +```dart + case Pages.live_location_tracking: + return Icons.my_location; +``` + +`getPath` — add before the `undefined`/`null` case: + +```dart + case Pages.live_location_tracking: + return '/liveTracking'; +``` + +`getLocalizedTitle` — add a case in the `switch (id)`: + +```dart + case 'live_location_tracking': + return s.liveTrackingMenuTitle; +``` + +- [ ] **Step 5: Rename the route + drop the spike** + +Replace `lib/config/routes/v2/routes_config/routes/location_tracking_routes.dart` with: + +```dart +import 'package:go_router/go_router.dart'; +import 'package:thingsboard_app/modules/location_tracking/presentation/view/live_tracking_page.dart'; + +class LocationTrackingRoutes { + static const liveTracking = '/liveTracking'; +} + +final List locationTrackingRoutes = [ + GoRoute( + path: LocationTrackingRoutes.liveTracking, + builder: (context, state) { + return const LiveTrackingPage(); + }, + ), +]; +``` + +- [ ] **Step 6: Update the bar's push target** + +In `lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart`, change: + +```dart + onTap: () => context.push(LocationTrackingRoutes.liveTracking), +``` + +- [ ] **Step 7: Remove the debug spike menu entry** + +In `lib/modules/more/more_page.dart`, delete the `if (kDebugMode) MoreMenuItemWidget(... liveTrackingSpike ...)` block (the block at ~L85-96) and remove the now-unused `kDebugMode` import if nothing else uses it (check first: `grep -n kDebugMode lib/modules/more/more_page.dart`). + +- [ ] **Step 8: Delete the spike page** + +```bash +git rm lib/modules/location_tracking/presentation/view/live_tracking_spike_page.dart +``` + +- [ ] **Step 9: Run tests, analyze, commit** + +```bash +flutter test test/modules/location_tracking/ +flutter analyze 2>&1 | grep -E "navigation_helper|location_tracking_routes|more_page|live_tracking_bar|pages_layout" ; echo "expect no output above" +dart format lib/utils/services/layouts/pages_layout.dart lib/modules/main/providers/navigation_helper.dart lib/config/routes/v2/routes_config/routes/location_tracking_routes.dart lib/modules/more/more_page.dart lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart test/modules/location_tracking/navigation_helper_live_tracking_test.dart +git add lib/ test/modules/location_tracking/navigation_helper_live_tracking_test.dart +git commit -m "feat(location): map live tracking bundle page and remove debug spike" +``` + +--- + +### Task 9: Flutter — collapsed bar polish (full-width, pulsing icon) + +**Files:** +- Modify: `lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart` +- Test: `test/modules/location_tracking/live_tracking_bar_test.dart` (existing — add case) + +**Interfaces:** +- Consumes: `liveTrackingProvider` (session + hidden). No new produced interface. + +- [ ] **Step 1: Add the failing widget test** + +Add to `test/modules/location_tracking/live_tracking_bar_test.dart` a case asserting the collapsed bar is full-width and shows `gps_fixed` while tracking. (Match the existing test file's harness for providing a session with `hidden: true`; the existing file already builds the bar with an overridden provider — mirror it.) + +```dart + testWidgets('collapsed bar spans full width and shows gps_fixed when tracking', + (tester) async { + // Arrange: session active + hidden == true (reuse this file's existing + // helper that pumps LiveTrackingBar with a tracking session, then tap Hide + // or seed hidden=true as the existing tests do). + // Assert: + expect(find.byIcon(Icons.gps_fixed), findsOneWidget); + final material = tester.widget( + find.ancestor(of: find.byIcon(Icons.gps_fixed), matching: find.byType(Material)).first, + ); + expect(material.color, isNotNull); + final size = tester.getSize(find.byType(Material).first); + expect(size.width, tester.view.physicalSize.width / tester.view.devicePixelRatio); + }); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `flutter test test/modules/location_tracking/live_tracking_bar_test.dart` +Expected: FAIL — collapsed bar is not full-width / not pulsing yet. + +- [ ] **Step 3: Implement the pulsing full-width collapsed bar** + +In `live_tracking_bar.dart`, add imports: + +```dart +import 'package:flutter_hooks/flutter_hooks.dart'; +``` + +Replace the `if (viewState.hidden) { return Material(...); }` block with a call to a new widget, and add that widget at the bottom of the file: + +```dart + if (viewState.hidden) { + return _CollapsedBar(tracking: tracking); + } +``` + +```dart +class _CollapsedBar extends HookConsumerWidget { + const _CollapsedBar({required this.tracking}); + + final bool tracking; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = Theme.of(context).colorScheme; + final controller = useAnimationController( + duration: const Duration(milliseconds: 900), + ); + useEffect(() { + if (tracking) { + controller.repeat(reverse: true); + } else { + controller.stop(); + controller.value = 1; + } + return null; + }, [tracking]); + + return Material( + color: colors.primaryContainer, + child: InkWell( + onTap: () => ref.read(liveTrackingProvider.notifier).show(), + child: SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Center( + child: FadeTransition( + opacity: Tween(begin: 0.35, end: 1).animate(controller), + child: Icon( + tracking ? Icons.gps_fixed : Icons.gps_off, + size: 18, + color: colors.onPrimaryContainer, + ), + ), + ), + ), + ), + ), + ); + } +} +``` + +Change the outer `LiveTrackingBar` from `ConsumerWidget` to `ConsumerWidget` (unchanged) — only the collapsed branch is extracted. Keep the expanded `Material(...)` return as-is. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `flutter test test/modules/location_tracking/live_tracking_bar_test.dart` +Expected: PASS. + +- [ ] **Step 5: Format, analyze, commit** + +```bash +flutter analyze 2>&1 | grep live_tracking_bar ; echo "expect no output above" +dart format lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart test/modules/location_tracking/live_tracking_bar_test.dart +git add lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart test/modules/location_tracking/live_tracking_bar_test.dart +git commit -m "feat(location): full-width pulsing collapsed tracking bar" +``` + +--- + +### Task 10: Flutter — stop tracking and clear the record on logout + +**Files:** +- Modify: `lib/core/context/tb_context.dart` (`logout`, ~L299-314) + +**Interfaces:** +- Consumes: `ILiveLocationTrackingService`, `ILiveTrackingStore` via `getIt`. + +- [ ] **Step 1: Add the teardown to logout** + +In `lib/core/context/tb_context.dart`, inside `logout(...)`, before `await tbClient.logout(...)`, add: + +```dart + await getIt().stop(); + await getIt().clear(); +``` + +Add imports if not present: + +```dart +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +``` + +- [ ] **Step 2: Analyze, format, commit** + +```bash +flutter analyze 2>&1 | grep tb_context ; echo "expect no output above" +dart format lib/core/context/tb_context.dart +git add lib/core/context/tb_context.dart +git commit -m "feat(location): stop tracking and clear last-session record on logout" +``` + +--- + +### Task 11: End-to-end smoke test (manual, real Android device) + +**Files:** none (verification only). + +- [ ] **Step 1: Stack** — local TB CE backend built with the Task 1 backend change; `yarn start` ui-ngx with the Task 1 model change; Flutter debug build on the Android 16 phone. In **Mobile center → Bundles → Layout**, confirm "Live location tracking" appears in the page list and is **off by default**; toggle it **on** and save. + +- [ ] **Step 2: Menu entry** — reopen the app (re-login so the bundle reloads). "Live location tracking" appears in the More menu with the `my_location` icon; the debug "GPS tracking spike" entry is gone. + +- [ ] **Step 3: Friendly name** — start tracking from a dashboard action targeting a named device; open the page via the bar → the Target row shows the device **name** (not `DEVICE `). Kill network briefly → falls back to `DEVICE · ` without error. + +- [ ] **Step 4: Idle + Start again** — stop tracking; open the page from the menu → shows the **last session** summary (name, ended, end reason "Stopped manually", counts, last coordinates) + **Start again**. Tap it → a new session starts for the same entity, `gpsActive=true`, `gpsTrackedBy` = the currently logged-in user. + +- [ ] **Step 5: End reasons** — start with max duration 1 min → after auto-stop the idle page shows end reason "Reached max duration". Force-kill the app mid-session, relaunch, open the page → shows "Interrupted" with Start again still available. + +- [ ] **Step 6: Collapsed bar** — Hide the bar → it collapses to a **full-width** purple bar with a **pulsing** `gps_fixed` icon while tracking; pause → static `gps_off`; tap → expands. + +- [ ] **Step 7: Logout** — log out while a session is active → tracking stops (`gpsActive=false`); log back in → the idle page shows "No active tracking and no recent session." (record cleared). diff --git a/docs/superpowers/plans/2026-07-23-browser-location-save.md b/docs/superpowers/plans/2026-07-23-browser-location-save.md new file mode 100644 index 00000000..0ecd42ea --- /dev/null +++ b/docs/superpowers/plans/2026-07-23-browser-location-save.md @@ -0,0 +1,833 @@ +# Browser-side Location Save (widget action) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a new generic `saveBrowserLocation` widget action that reads the browser's current geolocation once on trigger and saves the coordinates (plus optional browser metrics) to a resolvable target entity. + +**Architecture:** A new `WidgetActionType.saveBrowserLocation` handled in `WidgetComponent`'s action switch. It reuses the existing mobile-action target-entity model (`MobileActionTargetEntityConfig` + `MobileActionSaveAs`) and the existing save path (`AttributeService`). A new small `BrowserGeolocationService` wraps `navigator.geolocation` (greenfield — nothing like it exists). A new editor component mirrors the mobile-action editor's target-entity sub-form. Web-only: no backend, no mobile-app, no map involvement. + +**Tech Stack:** Angular 18 (ui-ngx), RxJS, Angular reactive forms + ControlValueAccessor, `@ngx-translate`. + +## Global Constraints + +- Repo: `thingsboard` at `/home/artem/projects/thingsboard`, branch `feat/gps-tracker` (the branch that already carries the phase-1d bundle-page change). All work is under `ui-ngx/`. Leave the pre-existing uncommitted `ui-ngx/proxy.conf.js` change alone. +- Conventional Commits; **no** `Co-Authored-By` lines. +- Verification per task (repo convention — **no test scaffolding in ui-ngx**): `cd /home/artem/projects/thingsboard/ui-ngx && npx tsc --noEmit -p src/tsconfig.app.json` must exit 0 (only any pre-existing photoswipe errors are acceptable). +- User-facing strings are localized in `ui-ngx/src/assets/locale/locale.constant-en_US.json` only (other locales fall back to English). +- Reuse existing enums/translations rather than duplicating: `MobileActionTargetEntityType`, `MobileActionAttributeSource`, `MobileActionSaveAs`, and their `*TranslationMap`s. +- The wire/type string is exactly `saveBrowserLocation` (enum member name === value). +- Spec: `docs/superpowers/specs/2026-07-23-browser-location-save-design.md` (in the flutter repo, alongside the other GPS specs). + +--- + +### Task 1: Model — enum member, descriptor interface, config field, translation + +**Files:** +- Modify: `ui-ngx/src/app/shared/models/widget.models.ts` (enum ~L611-621; `widgetActionTypeTranslationMap` ~L672-684; `WidgetAction` ~L887-911; add the new interface near the mobile save-location descriptors ~L824-862) + +**Interfaces:** +- Consumes: `MobileActionTargetEntityConfig` (`widget.models.ts:816-822`), `MobileActionSaveAs` (`:804-807`). +- Produces: `WidgetActionType.saveBrowserLocation`; `SaveBrowserLocationDescriptor`; `WidgetAction.saveBrowserLocation?: SaveBrowserLocationDescriptor`. Consumed by Tasks 3 (dispatch) and 5 (editor). + +- [ ] **Step 1: Add the enum member** + +In `widget.models.ts`, add `saveBrowserLocation` to `WidgetActionType` (after `placeMapItem`): + +```ts +export enum WidgetActionType { + doNothing = 'doNothing', + openDashboardState = 'openDashboardState', + updateDashboardState = 'updateDashboardState', + openDashboard = 'openDashboard', + custom = 'custom', + customPretty = 'customPretty', + mobileAction = 'mobileAction', + openURL = 'openURL', + placeMapItem = 'placeMapItem', + saveBrowserLocation = 'saveBrowserLocation' +} +``` + +(`saveBrowserLocation` is NOT filtered out of `widgetActionTypes` — unlike `placeMapItem` — so it becomes user-selectable automatically.) + +- [ ] **Step 2: Add the translation-map entry** + +Add to `widgetActionTypeTranslationMap` (after the `placeMapItem` entry): + +```ts + [ WidgetActionType.placeMapItem, 'widget-action.place-map-item' ], + [ WidgetActionType.saveBrowserLocation, 'widget-action.save-browser-location' ], +``` + +- [ ] **Step 3: Add the descriptor interface** + +Add near the existing `SaveLocationDescriptor`/`StartLiveLocationDescriptor` block (~L824-862): + +```ts +export interface SaveBrowserLocationDescriptor { + targetEntity?: MobileActionTargetEntityConfig; + saveAs?: MobileActionSaveAs; + latitudeKey?: string; + longitudeKey?: string; + accuracyKey?: string; + altitudeKey?: string; + altitudeAccuracyKey?: string; + headingKey?: string; + speedKey?: string; + timestampKey?: string; +} +``` + +- [ ] **Step 4: Add the config field to `WidgetAction`** + +In `interface WidgetAction` (near `mobileAction?`/`mapItemType?`, ~L908-911), add: + +```ts + mobileAction?: WidgetMobileActionDescriptor; + url?: string; + mapItemType?: MapItemType; + mapItemTooltips?: MapItemTooltips; + saveBrowserLocation?: SaveBrowserLocationDescriptor; +``` + +- [ ] **Step 5: Verify + commit** + +```bash +cd /home/artem/projects/thingsboard/ui-ngx && npx tsc --noEmit -p src/tsconfig.app.json ; echo "exit: $?" +``` +Expected: exit 0 (only any pre-existing photoswipe errors). + +```bash +cd /home/artem/projects/thingsboard +git add ui-ngx/src/app/shared/models/widget.models.ts +git commit -m "feat(widget): add saveBrowserLocation widget action type and descriptor" +``` + +--- + +### Task 2: i18n keys + +**Files:** +- Modify: `ui-ngx/src/assets/locale/locale.constant-en_US.json` (the `"widget-action"` block starts at L7685; its nested `"mobile"` object closes at ~L7796) + +**Interfaces:** +- Produces: translation keys `widget-action.save-browser-location` (the type label) and the `widget-action.browser-location.*` object (toasts, geolocation errors, optional-key labels). Consumed by Tasks 3 and 5. The service in Task 3 hard-codes the `widget-action.browser-location.error-*` keys, so the names must match exactly. + +- [ ] **Step 1: Add the type label + the `browser-location` sub-object** + +Inside the top-level `"widget-action"` object (a good spot is immediately after the `"mobile": { ... }` object closes at ~L7796), add the type label and a sibling namespace object. Match the file's 2-space-incremental indentation and trailing-comma style: + +```json + "save-browser-location": "Save browser location", + "browser-location": { + "location-saved": "Browser location saved", + "location-save-failed": "Failed to save browser location: {{error}}", + "error-insecure-context": "Browser location requires a secure (HTTPS) connection", + "error-permission-denied": "Location permission was denied", + "error-position-unavailable": "Current location is unavailable", + "error-timeout": "Timed out while getting the current location", + "optional-key-hint": "Leave blank to skip saving this value", + "accuracy-key": "Accuracy key", + "altitude-key": "Altitude key", + "altitude-accuracy-key": "Altitude accuracy key", + "heading-key": "Heading key", + "speed-key": "Speed key", + "timestamp-key": "Timestamp key" + }, +``` + +(The editor in Task 5 reuses the existing `widget-action.mobile.*` labels for target-entity/save-as/latitude/longitude, so those are not re-added here.) + +- [ ] **Step 2: Verify the JSON is well-formed + commit** + +```bash +cd /home/artem/projects/thingsboard/ui-ngx && node -e "JSON.parse(require('fs').readFileSync('src/assets/locale/locale.constant-en_US.json','utf8')); console.log('valid json')" +``` +Expected: `valid json`. + +```bash +cd /home/artem/projects/thingsboard +git add ui-ngx/src/assets/locale/locale.constant-en_US.json +git commit -m "feat(widget): add i18n for saveBrowserLocation action" +``` + +--- + +### Task 3: Browser geolocation service + +**Files:** +- Create: `ui-ngx/src/app/core/services/browser-geolocation.service.ts` + +**Interfaces:** +- Produces: `BrowserGeolocationService.getCurrentPosition(): Observable` (root-provided); `BrowserGeolocationError` (carries a `BrowserGeolocationErrorType`); `browserGeolocationErrorMessageKey(error): string` (pure — maps to a `widget-action.browser-location.error-*` i18n key). Consumed by Task 4. + +- [ ] **Step 1: Create the service** + +Create `ui-ngx/src/app/core/services/browser-geolocation.service.ts`: + +```ts +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; +import { Observable } from 'rxjs'; + +export enum BrowserGeolocationErrorType { + insecureContext = 'insecureContext', + permissionDenied = 'permissionDenied', + positionUnavailable = 'positionUnavailable', + timeout = 'timeout' +} + +export class BrowserGeolocationError extends Error { + constructor(public readonly errorType: BrowserGeolocationErrorType) { + super(errorType); + } +} + +const browserGeolocationErrorMessageKeys: {[key in BrowserGeolocationErrorType]: string} = { + [BrowserGeolocationErrorType.insecureContext]: 'widget-action.browser-location.error-insecure-context', + [BrowserGeolocationErrorType.permissionDenied]: 'widget-action.browser-location.error-permission-denied', + [BrowserGeolocationErrorType.positionUnavailable]: 'widget-action.browser-location.error-position-unavailable', + [BrowserGeolocationErrorType.timeout]: 'widget-action.browser-location.error-timeout' +}; + +export function browserGeolocationErrorMessageKey(error: unknown): string { + const errorType = error instanceof BrowserGeolocationError + ? error.errorType + : BrowserGeolocationErrorType.positionUnavailable; + return browserGeolocationErrorMessageKeys[errorType]; +} + +function toBrowserGeolocationError(error: GeolocationPositionError): BrowserGeolocationError { + switch (error.code) { + case error.PERMISSION_DENIED: + return new BrowserGeolocationError(BrowserGeolocationErrorType.permissionDenied); + case error.TIMEOUT: + return new BrowserGeolocationError(BrowserGeolocationErrorType.timeout); + default: + return new BrowserGeolocationError(BrowserGeolocationErrorType.positionUnavailable); + } +} + +@Injectable({ + providedIn: 'root' +}) +export class BrowserGeolocationService { + + getCurrentPosition(): Observable { + return new Observable((subscriber) => { + if (!window.isSecureContext || !navigator.geolocation) { + subscriber.error(new BrowserGeolocationError(BrowserGeolocationErrorType.insecureContext)); + return; + } + navigator.geolocation.getCurrentPosition( + (position) => { + subscriber.next(position); + subscriber.complete(); + }, + (error) => subscriber.error(toBrowserGeolocationError(error)), + { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 } + ); + }); + } +} +``` + +- [ ] **Step 2: Verify + commit** + +```bash +cd /home/artem/projects/thingsboard/ui-ngx && npx tsc --noEmit -p src/tsconfig.app.json ; echo "exit: $?" +``` +Expected: exit 0. + +```bash +cd /home/artem/projects/thingsboard +git add ui-ngx/src/app/core/services/browser-geolocation.service.ts +git commit -m "feat(widget): add browser geolocation service" +``` + +--- + +### Task 4: Dispatch handler in WidgetComponent + +**Files:** +- Modify: `ui-ngx/src/app/modules/home/components/widget/widget.component.ts` (imports; constructor ~L227; action switch ~L1214-1228; resolver ~L1573; add the new handler) + +**Interfaces:** +- Consumes: `BrowserGeolocationService`/`BrowserGeolocationError`/`browserGeolocationErrorMessageKey` (Task 3), `SaveBrowserLocationDescriptor`/`MobileActionSaveAs`/`MobileActionTargetEntityConfig` (Task 1), `AttributeService` (via `widgetContext`), `LatestTelemetry`/`AttributeScope`/`AttributeData`. +- Produces: runtime handling of `WidgetActionType.saveBrowserLocation`. The existing `resolveMobileActionTargetEntity` is refactored to delegate to a new generic `resolveActionTargetEntity(target, currentEntityId)` — behavior for the mobile action is unchanged. + +- [ ] **Step 1: Add imports** + +Add the new service import (group with other `@core` service imports): + +```ts +import { BrowserGeolocationError, browserGeolocationErrorMessageKey, BrowserGeolocationService } from '@core/services/browser-geolocation.service'; +``` + +Add `SaveBrowserLocationDescriptor` and `MobileActionTargetEntityConfig` to the existing import from `@shared/models/widget.models` (both live there; `MobileActionSaveAs` is already imported and used at L1531). Add `LatestTelemetry` to the existing import from `@shared/models/telemetry/telemetry.models` (`AttributeScope`, `AttributeData` are already imported/used at L1519/L1534). + +- [ ] **Step 2: Inject the service in the constructor** + +In the `WidgetComponent` constructor parameter list (near `private translate: TranslateService,` at L227), add: + +```ts + private translate: TranslateService, + private browserGeolocationService: BrowserGeolocationService, +``` + +- [ ] **Step 3: Add the dispatch case** + +In the action-type `switch` (the block ending at L1228), add a case after `mobileAction` and before the closing brace: + +```ts + case WidgetActionType.mobileAction: + const mobileAction = descriptor.mobileAction; + this.handleMobileAction($event, mobileAction, entityId, entityName, additionalParams, entityLabel); + break; + case WidgetActionType.saveBrowserLocation: + this.saveBrowserLocation(descriptor, entityId); + break; + } +``` + +- [ ] **Step 4: Refactor the target resolver to be generic (behavior-preserving)** + +Replace the header of `resolveMobileActionTargetEntity` (L1573) so it delegates to a new generic method, and rename the body's method to `resolveActionTargetEntity` taking the config directly. Concretely: keep everything from `const target = mobileAction.targetEntity;` onward, but move it into a new method whose parameter IS `target`, and make the old method a one-line delegator. The result: + +```ts + private resolveMobileActionTargetEntity(mobileAction: WidgetMobileActionDescriptor, + currentEntityId?: EntityId): Observable { + return this.resolveActionTargetEntity(mobileAction.targetEntity, currentEntityId); + } + + private resolveActionTargetEntity(target: MobileActionTargetEntityConfig, + currentEntityId?: EntityId): Observable { + const type = target?.type || MobileActionTargetEntityType.currentEntity; + switch (type) { + case MobileActionTargetEntityType.currentEntity: + if (validateEntityId(currentEntityId)) { + return of(currentEntityId); + } + return throwError(() => new Error('Widget action has no current entity')); + case MobileActionTargetEntityType.currentUser: + return of(this.currentUserEntityId()); + case MobileActionTargetEntityType.entityAlias: { + const aliasId = this.widgetContext.aliasController.getEntityAliasId(target.aliasName); + if (!aliasId) { + return throwError(() => new Error(`Entity alias '${target.aliasName}' not found in the dashboard`)); + } + return this.widgetContext.aliasController.resolveSingleEntityInfo(aliasId).pipe( + map((entity) => { + if (!entity?.id || !entity?.entityType) { + throw new Error(`Entity alias '${target.aliasName}' did not resolve to an entity`); + } + return {entityType: entity.entityType, id: entity.id} as EntityId; + }) + ); + } + case MobileActionTargetEntityType.fromAttribute: { + let sourceEntityId: EntityId; + if (target.attributeSource === MobileActionAttributeSource.currentEntity) { + if (!validateEntityId(currentEntityId)) { + return throwError(() => new Error('Widget action has no current entity')); + } + sourceEntityId = currentEntityId; + } else { + sourceEntityId = this.currentUserEntityId(); + } + return this.widgetContext.attributeService.getEntityAttributes( + sourceEntityId, AttributeScope.SERVER_SCOPE, [target.attributeKey], {ignoreErrors: true}).pipe( + map((attributes) => { + const attribute = attributes.find(a => a.key === target.attributeKey); + if (!attribute || !isDefinedAndNotNull(attribute.value)) { + throw new Error(`Attribute '${target.attributeKey}' not found on the source entity`); + } + return this.parseTargetEntityAttributeValue(attribute.value, target.defaultEntityType); + }) + ); + } + } + } +``` + +(This is the original body verbatim, only the entry point changed. `currentUserEntityId` and `parseTargetEntityAttributeValue` are unchanged.) + +- [ ] **Step 5: Add the `saveBrowserLocation` handler** + +Add this method (mirrors the existing `saveMobileActionLocation` at L1514, which is the same resolve→save→toast shape). Place it near `saveMobileActionLocation`: + +```ts + private saveBrowserLocation(descriptor: WidgetActionDescriptor, currentEntityId?: EntityId): void { + const config = descriptor.saveBrowserLocation; + if (!config) { + return; + } + defer(() => this.browserGeolocationService.getCurrentPosition()).pipe( + switchMap((position) => this.resolveActionTargetEntity(config.targetEntity, currentEntityId).pipe( + switchMap((targetEntityId) => { + const coords = position.coords; + const data: Array = []; + const addValue = (key: string | undefined, value: number | null | undefined) => { + const trimmed = (key || '').trim(); + if (trimmed.length && isDefinedAndNotNull(value) && !Number.isNaN(value)) { + data.push({key: trimmed, value}); + } + }; + addValue(config.latitudeKey || 'latitude', coords.latitude); + addValue(config.longitudeKey || 'longitude', coords.longitude); + addValue(config.accuracyKey, coords.accuracy); + addValue(config.altitudeKey, coords.altitude); + addValue(config.altitudeAccuracyKey, coords.altitudeAccuracy); + addValue(config.headingKey, coords.heading); + addValue(config.speedKey, coords.speed); + addValue(config.timestampKey, position.timestamp); + if (!data.length) { + return of(null); + } + if (config.saveAs === MobileActionSaveAs.timeseries) { + return this.widgetContext.attributeService.saveEntityTimeseries( + targetEntityId, LatestTelemetry.LATEST_TELEMETRY, data, {ignoreErrors: true}); + } + return this.widgetContext.attributeService.saveEntityAttributes( + targetEntityId, AttributeScope.SERVER_SCOPE, data, {ignoreErrors: true}); + }) + )) + ).subscribe({ + next: () => { + this.widgetContext.showSuccessToast( + this.translate.instant('widget-action.browser-location.location-saved')); + }, + error: (err) => { + if (err instanceof BrowserGeolocationError) { + this.widgetContext.showErrorToast(this.translate.instant(browserGeolocationErrorMessageKey(err))); + } else { + const message = err?.error?.message || err?.message || JSON.stringify(err); + this.widgetContext.showErrorToast( + this.translate.instant('widget-action.browser-location.location-save-failed', {error: message})); + } + } + }); + } +``` + +(Note: uses `LatestTelemetry.LATEST_TELEMETRY` for the timeseries scope — the correct value per `TbMap.saveItemData` — rather than the literal `'scope'` seen in the older `saveMobileActionLocation`. `defer`, `switchMap`, `of` are already imported in this file.) + +- [ ] **Step 6: Verify + commit** + +```bash +cd /home/artem/projects/thingsboard/ui-ngx && npx tsc --noEmit -p src/tsconfig.app.json ; echo "exit: $?" +``` +Expected: exit 0. + +```bash +cd /home/artem/projects/thingsboard +git add ui-ngx/src/app/modules/home/components/widget/widget.component.ts +git commit -m "feat(widget): handle saveBrowserLocation action at runtime" +``` + +--- + +### Task 5: Action config editor + wiring + +**Files:** +- Create: `ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/save-browser-location-action-editor.component.ts` +- Create: `ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/save-browser-location-action-editor.component.html` +- Modify: `ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts` (import + declarations ~L304-310 + exports ~L416-419) +- Modify: `ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts` (`updateActionTypeFormGroup` ~L244-348) +- Modify: `ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html` (the `ngSwitch` ~L286-289) + +**Interfaces:** +- Consumes: `SaveBrowserLocationDescriptor` (Task 1); `MobileActionTargetEntityType`/`MobileActionAttributeSource`/`MobileActionSaveAs` + their `*TranslationMap`s; `WidgetActionCallbacks` (for `fetchEntityAliases`). +- Produces: the `tb-save-browser-location-action-editor` component; the `saveBrowserLocation` control wired into `WidgetActionComponent`. The generic branch of `widgetActionUpdated` (`{...widgetActionFormGroup.value, ...actionTypeFormGroup.value}`) nests the value under `saveBrowserLocation`, matching Task 1's `WidgetAction.saveBrowserLocation`. + +- [ ] **Step 1: Create the editor component `.ts`** + +Create `save-browser-location-action-editor.component.ts`: + +```ts +/// +/// Copyright © 2016-2025 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, Input, OnDestroy, OnInit } from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, + UntypedFormBuilder, + UntypedFormGroup, + Validators +} from '@angular/forms'; +import { + MobileActionAttributeSource, + mobileActionAttributeSourceTranslationMap, + MobileActionSaveAs, + mobileActionSaveAsTranslationMap, + MobileActionTargetEntityType, + mobileActionTargetEntityTypeTranslationMap, + SaveBrowserLocationDescriptor, + WidgetActionCallbacks +} from '@shared/models/widget.models'; +import { Observable, Subject } from 'rxjs'; +import { map, startWith, takeUntil } from 'rxjs/operators'; + +@Component({ + selector: 'tb-save-browser-location-action-editor', + templateUrl: './save-browser-location-action-editor.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SaveBrowserLocationActionEditorComponent), + multi: true + } + ], + standalone: false +}) +export class SaveBrowserLocationActionEditorComponent implements ControlValueAccessor, OnInit, OnDestroy { + + @Input() + disabled: boolean; + + @Input() + callbacks: WidgetActionCallbacks; + + formGroup: UntypedFormGroup; + + targetEntityTypes = Object.values(MobileActionTargetEntityType); + targetEntityType = MobileActionTargetEntityType; + targetEntityTypeTranslations = mobileActionTargetEntityTypeTranslationMap; + attributeSources = Object.values(MobileActionAttributeSource); + attributeSourceTranslations = mobileActionAttributeSourceTranslationMap; + saveAsOptions = Object.values(MobileActionSaveAs); + saveAsTranslations = mobileActionSaveAsTranslationMap; + + entityAliasNames: string[] = []; + filteredEntityAliasNames: Observable; + + private destroy$ = new Subject(); + private propagateChange = (_val: any) => {}; + + constructor(private fb: UntypedFormBuilder) {} + + ngOnInit(): void { + this.formGroup = this.fb.group({ + targetEntity: this.fb.group({ + type: [MobileActionTargetEntityType.currentEntity, []], + aliasName: [null, []], + attributeSource: [MobileActionAttributeSource.currentUser, []], + attributeKey: [null, []], + defaultEntityType: [null, []] + }), + saveAs: [MobileActionSaveAs.attributes, []], + latitudeKey: ['latitude', [Validators.required]], + longitudeKey: ['longitude', [Validators.required]], + accuracyKey: ['', []], + altitudeKey: ['', []], + altitudeAccuracyKey: ['', []], + headingKey: ['', []], + speedKey: ['', []], + timestampKey: ['', []] + }); + + this.entityAliasNames = (this.callbacks?.fetchEntityAliases?.() ?? []).map((alias) => alias.alias); + const aliasNameControl = this.formGroup.get('targetEntity.aliasName'); + this.filteredEntityAliasNames = aliasNameControl.valueChanges.pipe( + startWith(aliasNameControl.value ?? ''), + map((value: string) => (value ?? '').toLowerCase()), + map((search) => this.entityAliasNames.filter((name) => name.toLowerCase().includes(search))) + ); + + this.formGroup.get('targetEntity.type').valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(() => this.updateTargetEntityValidators()); + + this.formGroup.valueChanges.pipe( + takeUntil(this.destroy$) + ).subscribe(() => this.propagateChange(this.formGroup.getRawValue())); + + this.updateTargetEntityValidators(); + } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void {} + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.formGroup.disable({emitEvent: false}); + } else { + this.formGroup.enable({emitEvent: false}); + this.updateTargetEntityValidators(); + } + } + + writeValue(value?: SaveBrowserLocationDescriptor): void { + this.formGroup.patchValue({ + targetEntity: { + type: value?.targetEntity?.type || MobileActionTargetEntityType.currentEntity, + aliasName: value?.targetEntity?.aliasName ?? null, + attributeSource: value?.targetEntity?.attributeSource || MobileActionAttributeSource.currentUser, + attributeKey: value?.targetEntity?.attributeKey ?? null, + defaultEntityType: value?.targetEntity?.defaultEntityType ?? null + }, + saveAs: value?.saveAs || MobileActionSaveAs.attributes, + latitudeKey: value?.latitudeKey || 'latitude', + longitudeKey: value?.longitudeKey || 'longitude', + accuracyKey: value?.accuracyKey ?? '', + altitudeKey: value?.altitudeKey ?? '', + altitudeAccuracyKey: value?.altitudeAccuracyKey ?? '', + headingKey: value?.headingKey ?? '', + speedKey: value?.speedKey ?? '', + timestampKey: value?.timestampKey ?? '' + }, {emitEvent: false}); + this.updateTargetEntityValidators(); + } + + private updateTargetEntityValidators(): void { + const type: MobileActionTargetEntityType = this.formGroup.get('targetEntity.type').value; + const aliasName = this.formGroup.get('targetEntity.aliasName'); + const attributeKey = this.formGroup.get('targetEntity.attributeKey'); + aliasName.setValidators(type === MobileActionTargetEntityType.entityAlias ? [Validators.required] : []); + attributeKey.setValidators(type === MobileActionTargetEntityType.fromAttribute ? [Validators.required] : []); + aliasName.updateValueAndValidity({emitEvent: false}); + attributeKey.updateValueAndValidity({emitEvent: false}); + } +} +``` + +- [ ] **Step 2: Create the editor `.html`** + +Create `save-browser-location-action-editor.component.html`. The target-entity block mirrors `mobile-action-editor.component.html`'s `#targetEntityConfig` (reusing its i18n keys): + +```html +
+ +
+
{{ 'widget-action.mobile.target-entity-type' | translate }}
+ + + + {{ targetEntityTypeTranslations.get(type) | translate }} + + + +
+ @if (formGroup.get('targetEntity.type').value === targetEntityType.entityAlias) { +
+
{{ 'widget-action.mobile.target-entity-alias-name' | translate }}*
+ + + + + {{ name }} + + + +
+ } + @if (formGroup.get('targetEntity.type').value === targetEntityType.fromAttribute) { +
+
{{ 'widget-action.mobile.target-attribute-source' | translate }}
+ + + + {{ attributeSourceTranslations.get(source) | translate }} + + + +
+
+
{{ 'widget-action.mobile.target-attribute-key' | translate }}*
+ + + +
+
+
{{ 'widget-action.mobile.target-default-entity-type' | translate }}
+ + +
+ } +
+ +
+
{{ 'widget-action.mobile.save-as' | translate }}
+ + + + {{ saveAsTranslations.get(option) | translate }} + + + +
+ +
+
{{ 'widget-action.mobile.latitude-key' | translate }}
+ + + +
+
+
{{ 'widget-action.mobile.longitude-key' | translate }}
+ + + +
+ +
{{ 'widget-action.browser-location.optional-key-hint' | translate }}
+
+
{{ 'widget-action.browser-location.accuracy-key' | translate }}
+ + + +
+
+
{{ 'widget-action.browser-location.altitude-key' | translate }}
+ + + +
+
+
{{ 'widget-action.browser-location.altitude-accuracy-key' | translate }}
+ + + +
+
+
{{ 'widget-action.browser-location.heading-key' | translate }}
+ + + +
+
+
{{ 'widget-action.browser-location.speed-key' | translate }}
+ + + +
+
+
{{ 'widget-action.browser-location.timestamp-key' | translate }}
+ + + +
+
+``` + +- [ ] **Step 3: Register the component in the module** + +In `widget-settings-common.module.ts`: + +Add the import (near the other action editor imports ~L71-82): + +```ts +import { SaveBrowserLocationActionEditorComponent } from '@home/components/widget/lib/settings/common/action/save-browser-location-action-editor.component'; +``` + +Add to the `declarations` array (near `MobileActionEditorComponent` at ~L308): + +```ts + MobileActionEditorComponent, + SaveBrowserLocationActionEditorComponent, +``` + +Add to the `exports` array (near `MobileActionEditorComponent` at ~L419): + +```ts + MobileActionEditorComponent, + SaveBrowserLocationActionEditorComponent, +``` + +- [ ] **Step 4: Add the form control in `WidgetActionComponent`** + +In `widget-action.component.ts`, in `updateActionTypeFormGroup(...)`, add a case alongside `mobileAction` (~L312-317): + +```ts + case WidgetActionType.saveBrowserLocation: + this.actionTypeFormGroup.addControl( + 'saveBrowserLocation', + this.fb.control(action ? action.saveBrowserLocation : null, [Validators.required]) + ); + break; +``` + +(The generic branch of `widgetActionUpdated` already spreads `actionTypeFormGroup.value`, so the control's value is emitted under the `saveBrowserLocation` key — no change needed there.) + +- [ ] **Step 5: Render the editor in `widget-action.component.html`** + +Add a `ngSwitchCase` alongside the `mobileAction` one (~L286-289): + +```html + + + + +``` + +- [ ] **Step 6: Verify + commit** + +```bash +cd /home/artem/projects/thingsboard/ui-ngx && npx tsc --noEmit -p src/tsconfig.app.json ; echo "exit: $?" +``` +Expected: exit 0. + +```bash +cd /home/artem/projects/thingsboard +git add ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/save-browser-location-action-editor.component.ts \ + ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/save-browser-location-action-editor.component.html \ + ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts \ + ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts \ + ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.html +git commit -m "feat(widget): add saveBrowserLocation action config editor" +``` + +--- + +### Task 6: Manual smoke test (verification only) + +**Files:** none. + +- [ ] **Step 1** — `yarn start` ui-ngx over HTTPS (or a secure-context dev setup). On a dashboard widget, add a widget action; confirm **"Save browser location"** appears in the action-type dropdown. +- [ ] **Step 2** — Configure it: target **Current entity**, saveAs **Attributes**, keep latitude/longitude, set `accuracy` key. Trigger the action → browser prompts for location → on allow, a success toast appears and the entity gains `latitude`/`longitude`/`accuracy` server-scope attributes. +- [ ] **Step 3** — Set saveAs **Time series** → values land as latest telemetry instead. +- [ ] **Step 4** — Target **Entity alias** and **From attribute** → saves to the resolved entity; a bad alias/attribute shows the save-failed error toast. +- [ ] **Step 5** — Deny the permission prompt → permission-denied toast. Load the dashboard over plain HTTP → insecure-context toast (no crash). +- [ ] **Step 6** — Leave optional keys (altitude/heading/speed) blank or unavailable on desktop → they are simply absent from the entity (no attributes deleted, no error). diff --git a/docs/superpowers/specs/2026-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. diff --git a/docs/superpowers/specs/2026-07-22-gps-live-tracking-ux-design.md b/docs/superpowers/specs/2026-07-22-gps-live-tracking-ux-design.md new file mode 100644 index 00000000..4013b8c4 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-gps-live-tracking-ux-design.md @@ -0,0 +1,116 @@ +# GPS Live Tracking UX & Persistence (Phase 1d) Design + +**Status:** design — pending user review +**Date:** 2026-07-22 +**Repos:** Flutter `flutter_thingsboard_app` (UX + persistence) **and** `thingsboard` (backend `DefaultPageId` + ui-ngx bundle-layout editor) for the bundle-gated menu entry. No wire-protocol changes. +**Predecessor:** Phase 1c — `docs/superpowers/specs/2026-07-03-gps-tracking-design.md`, plan `docs/superpowers/plans/2026-07-03-gps-live-tracking.md` + +## Goal + +Polish the phase 1c live-tracking experience on the mobile app: + +1. Show the tracking **target as a human-friendly entity name** instead of `DEVICE `. +2. Replace the debug-only **"GPS tracking spike"** menu entry with a **"Live location tracking"** page that admins include/exclude per mobile bundle (server-driven, hidden by default). +3. Give that page a useful **idle state**: a persisted summary of the last session plus a **"Start again"** action, or a "nothing tracked" message when there is no history. +4. Polish the **collapsed tracking bar**: a full-width bar with a pulsing "live" icon. + +Explicitly **out of scope** (its own later spec): saving GPS to more than one entity (multi-entity tracking), which would change the wire protocol, the tracking service, and the ui-ngx action editor. + +## Scope note — app-initiated tracking + +Phase 1c starts a session only from a dashboard mobile action. The "Start again" button in this phase lets the app **relaunch a session from a stored config** without a dashboard round-trip. This is a deliberate, small expansion of the model; the config is still exactly what a dashboard action produced earlier. + +--- + +## 1. Friendly entity name + +- A Riverpod `FutureProvider.family` keyed by the target `EntityId` resolves the display name via the Dart client, cached so the session page and idle page share a single lookup. (Exact client call — e.g. an entity-info lookup by `entityType`+`id` — to be confirmed in the plan.) +- **Display:** the resolved name (e.g. `My Tracker Device`). While loading or on failure (offline, or the entity was deleted) it falls back to `DEVICE · `. +- The last successfully resolved name is also written into the persisted record (§3), so the idle page can show a name with no network call. + +## 2. Page promotion — bundle-gated menu entry (replaces the spike) + +The app's menu is server-driven: the mobile bundle sends a page layout the app maps through the `Pages` enum (`lib/utils/services/layouts/pages_layout.dart`) and renders via `ILayoutService.getMorePageItems(...)`. So exposure of the new page is a bundle page type, not a hardcoded entry — admins toggle it in **Mobile center → Bundles → Layout**, and it is **hidden by default**. + +**Backend (`thingsboard`):** +- Add `LIVE_LOCATION_TRACKING` to `common/data/.../mobile/layout/DefaultPageId.java`. +- Wire it through the default-page plumbing (`DefaultMobilePage` / `MobileLayoutConfig` defaults + any validation) so the bundle editor and stored layouts accept it. Not in the default visible set (opt-in). + +**ui-ngx (`thingsboard`):** +- Mirror the new page id in the bundle-layout models/labels (`ui-ngx/src/app/shared/models/mobile-app.models.ts`) with a display label + icon, so the Layout editor (`pages/mobile/bundes/layout/`) lists it as a toggle. +- Add an en_US locale string for the page name. + +**App (`flutter_thingsboard_app`):** +- Add `live_location_tracking` to the `Pages` enum and parsing. +- Map it in `layout_service.dart` (`getMorePageItems`) to the tracking page's route, icon, and default label; it renders only when the resolved bundle layout includes it. +- **Delete** the phase-1a spike: `lib/modules/location_tracking/presentation/view/live_tracking_spike_page.dart`, the `liveTrackingSpike` route constant + `GoRoute`, and the `kDebugMode`-gated entry in `lib/modules/more/more_page.dart`. +- Rename the route constant `liveTrackingSession` → `liveTracking` (and its path); the page now covers both active-session and idle states. Update the bar's `context.push(...)` accordingly. +- New l10n key for the page title in `lib/l10n/intl_en.arb`. + +This bundle entry is also what makes the idle "last session" page reachable when no session is active (the tracking bar only exists during/after a session). + +## 3. Idle state + persistence + +### Storage + +- New interface `ILiveTrackingStore` with a Hive-backed implementation, registered in GetIt (matching the project's interface + GetIt convention). Holds **one** `LastTrackingRecord` (or none). +- `LiveLocationTrackingService` gains a dependency on `ILiveTrackingStore` and: + - **On `start(config)`** — writes a record with the raw config JSON, `startedAt`, and (once resolved) the target name; `endReason = interrupted` provisionally, so an app-kill mid-session still leaves a restartable record. + - **On session end** (`stop` or max-duration — a terminal error *pauses* rather than ends, per phase 1c) — updates the record with `endedAt`, the final `fixCount`/`savedCount`/`saveErrorCount`, last coordinates, `lastError` if any, and the real `endReason`. +- **Cleared on logout** (device-global record; a different user must not see the previous user's last target). Hook into the existing logout/user-changed path. + +### Data model + +``` +LastTrackingRecord { + configJson: Map // raw wire config, replayed by "Start again" + targetName: String? // cached resolved name for offline idle display + startedAt: DateTime + endedAt: DateTime? + fixCount: int + savedCount: int + saveErrorCount: int + lastLat: double? + lastLng: double? + lastError: String? // last terminal error seen, if any + endReason: manual | maxDuration | interrupted +} +``` + +### Page behavior (`LiveTrackingPage`) + +- **Active session** → live details as today, with the friendly name in the Target row. +- **Idle with a record** → read-only summary (name, started/ended, counts, last coordinates, end reason) + a **"Start again"** button that calls `service.start(storedConfig)`, with `trackedBy` **re-derived from the current logged-in user** (not the stored email). +- **Idle, no record** → "No active tracking" message. + +## 4. Collapsed-bar polish + +- The collapsed (hidden) pill becomes a **full-width** `primaryContainer` bar (currently a small centered chip). +- Centered **pulsing icon** while `status == tracking`: an `AnimationController` with `repeat(reverse: true)` fading/scaling the `gps_fixed` icon. While `paused`, show a static `gps_off` icon (no pulse — it is not live). +- Tap still expands the bar (`show()`). +- The collapsed pill becomes a small `HookConsumerWidget` (the file already uses `hooks_riverpod`) so `useAnimationController` drives the pulse; the expanded bar is unchanged. + +## Error handling + +- Name resolution failure → silent fallback to `DEVICE · `; never blocks the page. +- Store read/write failure → logged via `TbLogger`, treated as "no record" (page still renders); never crashes tracking. +- "Start again" reuses the phase-1c `start()` path, so location-permission / services failures surface through the existing session error handling (paused + `lastError`). + +## Testing + +- `ILiveTrackingStore` unit tests: save/read round-trip; start-writes-then-end-updates; clear-on-logout. +- Name-fallback unit test: resolved name vs `DEVICE · ` on failure. +- Widget test for the bar: tracking → `gps_fixed`, paused → `gps_off`, collapsed bar spans full width. The pulse animation itself is not asserted. +- Existing phase-1c service tests must keep passing; the added store dependency is injected via a fake in those tests. + +## Out of scope / follow-ups + +Tracked as their own future specs (not part of 1d): + +- **Multi-entity tracking** — save each GPS fix to more than one target entity; changes the wire protocol, tracking service, and ui-ngx action editor. +- **Browser-side location save** — a new widget action available from the outer widget scope (like "Place map item") that reads the *browser's* geolocation and saves coordinates to an entity (current / user / alias / attribute-derived). Web-only (ui-ngx + existing telemetry-save APIs); does not touch the mobile app. + +Deliberately excluded from 1d itself: + +- History of more than one past session (only the single last record is kept). +- Editing tracking parameters from the app (config is replayed verbatim). diff --git a/docs/superpowers/specs/2026-07-23-browser-location-save-design.md b/docs/superpowers/specs/2026-07-23-browser-location-save-design.md new file mode 100644 index 00000000..7797d378 --- /dev/null +++ b/docs/superpowers/specs/2026-07-23-browser-location-save-design.md @@ -0,0 +1,105 @@ +# Browser-side Location Save (widget action) — Design Spec + +**Status:** Design approved, ready for implementation planning. +**Repo:** `thingsboard` / `ui-ngx` (Angular frontend). **This feature does not touch the mobile Flutter app or the backend** — it is a new frontend widget action built on existing telemetry-save HTTP APIs. +**Origin:** Follow-up carried over from `2026-07-22-gps-live-tracking-ux-design.md` ("Out of scope / follow-ups → Browser-side location save"). + +## Goal + +Add a new **generic** widget action, `saveBrowserLocation`, that reads the browser's current geolocation once (on user trigger) and writes the coordinates (and browser-meaningful metrics) to a configurable target entity via the existing attribute/telemetry save APIs. + +## Scope + +- **In:** a new `WidgetActionType.saveBrowserLocation` offered on any widget action; its config editor; runtime dispatch + geolocation capture + save; localized toasts. +- **Out:** map-widget-specific placement (this is not `placeMapItem`), continuous/watch tracking, multi-entity save, mobile app, backend/enum changes. See "Out of scope" below. + +## Architecture + +A new member of the existing `WidgetActionType` enum (`ui-ngx/src/app/shared/models/widget.models.ts:611-621`), handled generically like `custom` / `mobileAction` (i.e. **kept in** the generic `widgetActionTypes` list, unlike `placeMapItem` which is filtered out at `widget.models.ts:669-670`). No map or backend involvement. + +Three units, each independently understandable: + +1. **Config model + editor** — a `saveBrowserLocation` config object on `WidgetAction`, plus an editor sub-form under `ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/`. +2. **Geolocation helper** — a small, pure-ish service wrapping `navigator.geolocation.getCurrentPosition` as a Promise/Observable with a secure-context guard and typed error mapping. Nothing like this exists in the codebase today (greenfield). +3. **Dispatch handler** — a `saveBrowserLocation(...)` method wired into the action switch in `ui-ngx/src/app/modules/home/components/widget/widget.component.ts` (~`:1160-1228`), reusing the existing target-entity resolver and save path. + +## Capture model + +**One-shot on trigger.** A single `navigator.geolocation.getCurrentPosition(success, error, options)` per action invocation, with `options = { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }`. No `watchPosition`, no retry loop in v1. + +## Target-entity model (reuse existing) + +Reuse the mobile-action target model verbatim — the four modes already implemented by `resolveMobileActionTargetEntity(...)` at `widget.component.ts:1573-1621` (backed by `MobileActionTargetEntityType`): + +- **currentEntity** — the widget's active entity, via `getActiveEntityInfo()` (`widget.component.ts:1910-1922`). +- **currentUser** — `{ entityType: USER, id: authUser.userId }`, via `currentUserEntityId()` (`:1623-1626`). +- **entityAlias** — resolved through `AliasController` (`getEntityAliasId` → `resolveSingleEntityInfo`). +- **fromAttribute** — an `{entityType, id}` parsed from an attribute on the current entity or current user (`parseTargetEntityAttributeValue`, `:1628+`). + +The action config carries the same target descriptor shape the mobile action uses (target type + optional alias name + optional attribute source/key). The dispatch handler resolves it with the existing resolver rather than a parallel implementation. + +## Save destination (configurable data keys) + +Coordinates and metrics are written through configurable **data keys**, mirroring how map widgets define `xKey`/`yKey` and how `TbMap.saveItemData` (`map.ts:1303-1340`) batches by key type. + +**Required keys** (always saved): + +| Field | Default key name | Source | +|---|---|---| +| latitude | `latitude` | `coords.latitude` (degrees) | +| longitude | `longitude` | `coords.longitude` (degrees) | + +**Optional keys** (v1 — each has a key-name input in the editor; leaving a name blank means "don't save that field"; browser-meaningful `GeolocationCoordinates` / `GeolocationPosition` fields): + +| Field | Default key name | Source | Typical availability | +|---|---|---|---| +| accuracy | `accuracy` | `coords.accuracy` (m, horizontal) | always present | +| altitude | `altitude` | `coords.altitude` (m) | often null on non-GPS/desktop | +| altitudeAccuracy | `altitudeAccuracy` | `coords.altitudeAccuracy` (m) | null when altitude null | +| heading | `heading` | `coords.heading` (deg) | null/NaN when stationary or desktop | +| speed | `speed` | `coords.speed` (m/s) | null when stationary or desktop | +| timestamp | `locationTimestamp` | `position.timestamp` (epoch ms) | always present | + +**Save-as (reused, single setting for all keys):** one `saveAs` selector — **Attributes (server scope)** or **Time series** — applied to every saved key, reusing the existing `MobileActionSaveAs` enum and its translations (the same control the mobile "Save location to entity" action uses). Attributes are written to `SERVER_SCOPE`; time series to `LATEST_TELEMETRY`. Per-key type/scope is intentionally **not** offered (YAGNI; matches the existing mobile action). + +**Null-handling (important):** `AttributeService.saveEntityAttributes` treats a `null` value as a **delete** of that key (`attribute.service.ts:71-96`). Therefore an optional metric whose captured value is `null`/`undefined`/`NaN` (e.g. `altitude`/`heading`/`speed` on a desktop), or whose key name is blank, MUST be **omitted from the save payload entirely**, never sent as null — otherwise a stale attribute/series would be silently deleted. Required latitude/longitude are always numeric and always saved. + +**Save mechanism:** collect the enabled, non-null fields into one payload and call, per `saveAs`: +- `AttributeService.saveEntityAttributes(entityId, scope, AttributeData[])` (`attribute.service.ts:71`) for attribute keys, and +- `AttributeService.saveEntityTimeseries(entityId, LatestTelemetry.LATEST_TELEMETRY, AttributeData[])` (`:98`) for timeseries keys. + +## Runtime flow + +Dispatched from the action switch in `widget.component.ts`: + +1. **Secure-context guard:** if `!window.isSecureContext || !navigator.geolocation` → show the insecure-context error toast and stop (the Geolocation API is unavailable on non-HTTPS origins). +2. `getCurrentPosition(success, error, { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 })`. +3. **On success:** resolve the target entity (existing resolver) → build the attribute/timeseries batches from the enabled, non-null fields → save → **success toast**. If the target entity cannot be resolved, show the save-failed (or a target-unresolved) toast. +4. **On geolocation error:** map `error.code` to a specific localized toast: `PERMISSION_DENIED`, `POSITION_UNAVAILABLE`, `TIMEOUT`. +5. **On save (HTTP) error:** save-failed toast. + +## Feedback (toasts only) + +Localized strings added to `ui-ngx/src/assets/locale/locale.constant-en_US.json` (English only; other locales fall back to English per repo convention). Distinct messages for: success, insecure-context, permission-denied, position-unavailable, timeout, save-failed. **No** post-save custom-function hook (unlike `placeMapItem`'s `afterPlaceItemCallback`). + +## Files touched (all in `thingsboard/ui-ngx`) + +- `src/app/shared/models/widget.models.ts` — add `WidgetActionType.saveBrowserLocation`, its `widgetActionTypeTranslationMap` entry, and the config interface fields on `WidgetAction`; ensure it stays in the generic `widgetActionTypes` list. +- `src/app/modules/home/components/widget/widget.component.ts` — add the dispatch `case` and a `saveBrowserLocation(...)` handler; reuse `resolveMobileActionTargetEntity`, `getActiveEntityInfo`, `currentUserEntityId`. +- Action editor components under `src/app/modules/home/components/widget/lib/settings/common/action/` (e.g. `widget-action.component.*`) — a config sub-form: target-entity selector (reuse the mobile-action target sub-form/component if one exists) + required latitude/longitude key rows + optional metric key rows. +- `src/assets/locale/locale.constant-en_US.json` — new i18n keys. +- New geolocation helper (e.g. `src/app/core/services/browser-geolocation.service.ts`) — wraps `getCurrentPosition` + secure-context guard + typed error mapping; the error→message mapping and the field→key batching are written as pure functions so they are inspectable and (if the repo ever adds coverage) unit-testable. + +## Out of scope / follow-ups + +- Continuous / `watchPosition` tracking with a stop control. +- Multi-entity save (write one capture to several targets). +- Per-key advanced telemetry options beyond attribute-scope / latest-timeseries. +- Any mobile-app or backend change. + +## Global constraints (for the implementation plan) + +- Repo `thingsboard`, branch `feat/gps-tracker` (same branch the phase-1d Task 1 bundle-page change lives on). Leave the pre-existing uncommitted `ui-ngx/proxy.conf.js` change alone. +- Conventional Commits; **no** `Co-Authored-By` lines. +- ui-ngx verification: `cd ui-ngx && npx tsc --noEmit -p src/tsconfig.app.json` must exit 0 (only pre-existing photoswipe errors acceptable). Follow the repo convention of **no new test scaffolding**. +- User-facing strings localized in `locale.constant-en_US.json` only. diff --git a/docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md b/docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md new file mode 100644 index 00000000..ad985b43 --- /dev/null +++ b/docs/superpowers/specs/2026-07-31-location-save-single-entity-design.md @@ -0,0 +1,225 @@ +# Single-Entity Location Save + Action Editor Labels — Design Spec + +**Status:** Design approved, ready for implementation planning. +**Repo:** `thingsboard` / `ui-ngx` (Angular frontend). **No Flutter app or backend changes** — see "Mobile app impact" below. +**Branch:** `feat/gps-tracker` (nothing here is released; see "Compatibility"). +**Origin:** Review of the mobile action editor for `Get phone location`, which surfaced (a) two different fields both labelled "Target entity", and (b) the question of whether multi-entity save is a feature worth keeping. + +## Goal + +Make **all three** location widget actions save to exactly **one** target entity, failing loudly when the configuration resolves to more, and clean up the action editor labels that the current wording made ambiguous. + +The three actions: + +| Action | Where | Multiplicity today | +|---|---|---| +| `getLocation` (with *Save location to entity* on) | `mobileAction` | up to 100 entities via alias fan-out | +| `saveBrowserLocation` | generic widget action | up to 100 entities via alias fan-out | +| `startLiveLocation` | `mobileAction` | 1 — silently takes the alphabetically first | + +After this change all three behave identically: **one target, or an error.** + +## Rationale + +**A location fix is an observation about one physical body** — the phone, or the browser's host machine. Fan-out writes that single observation as N identical observations. A dashboard consuming the result cannot distinguish *"these 40 assets are genuinely at this spot"* from *"someone picked the wrong alias"*: all N receive byte-identical lat/lng from one sensor, with no aggregation and no per-entity differentiation. Fan-out is meaningful for *intent* (a command, a setpoint); it is not meaningful for a *measurement* of one thing. + +**The multi path is easy to hit by accident, and it congratulates you for it.** Most useful alias filter types resolve to sets by nature (`deviceType`, `assetType`, `relationsQuery`, `entityList`); the single-resolving ones (`singleEntity`, `stateEntity`) are the minority. Today a user who picks a set-valued alias gets a green success toast — *"Browser location saved to 40 entities"* — confirming the mistake. Silent wrongness with positive feedback, at write scale. + +**The 100-entity cap shows it was never a designed capability.** `aliasTargetsPageSize = 100` (`location.service.ts:74`), `totalTargets = max(totalElements, targets.length)` (`:269`) and the `location-saved-partial` warning exist only to bound an accident. An alias resolving 5,000 entities writes to 100 of them, ordered alphabetically by name, and warns. That is truncation, not a feature. + +**Single → multi is the reversible direction.** Adding fan-out later is additive (restore `saveToTargets`, gate it behind an explicit opt-in). Removing it later breaks customer dashboards. With no use case in hand, take the reversible option. + +**The one real use case is served differently.** Co-moving targets — a technician's phone writing to both their user entity and "Van 12" — wants 2–3 *deliberately named* targets, not an alias that happens to resolve to 2. That is a separate, cheaper feature if it is ever requested. Recorded under "Out of scope". + +## Compatibility + +**Nothing is shipped, so there is no migration.** `location.service.ts` was introduced in `f204f3f778b`; `git branch --contains` on that commit lists only `feat/gps-tracker`. On `master`, `WidgetMobileActionType.getLocation` carries only `getLocationFunction` — no `targetEntity`, no `saveToEntity`, no key mappings — and `saveBrowserLocation` does not exist. No dashboard in the wild can depend on fan-out. + +## Behaviour: one target, two layers of feedback + +Runtime resolution can only be checked at click time, so a runtime error is unavoidable. But `resolveMultiple` is a **static property of the alias filter** (`EntityAliasFilter.resolveMultiple`, `alias.models.ts:185-188`), not data-dependent, and the editor already holds every alias definition. So the misconfiguration is caught earlier as well. + +### Layer 1 — config-time warning (soft, non-blocking) + +In `LocationTargetEntityComponent`, when the selected alias's filter has `resolveMultiple === true`, show a warning next to the *Alias name* row: + +> This alias can resolve to multiple entities. Saving will fail unless it resolves to exactly one. + +**Soft, not a validation error.** A `deviceType` alias may legitimately match exactly one device today; blocking Save would be wrong. It informs, it does not prevent. + +The component already fetches the data it needs — `this.entityAliases = this.callbacks?.fetchEntityAliases?.()` (`location-target-entity.component.ts:155`) — and already looks aliases up by name in `updateAttributeSourceEntityFilter` (`:232-247`). Reuse that lookup; both `source` and `aliasName` already have `valueChanges` subscriptions to hang recomputation on. + +**Applies in both modes.** In *From attribute* mode the alias identifies the entity the attribute is *read from*, and `resolveAttributeSourceEntity` (`location.service.ts:346`) resolves it through the same single-alias path. A set-valued alias is equally ambiguous there. Since the warning attaches to the shared *Alias name* row (`location-target-entity.component.html:49-82`), one implementation covers both. + +### Layer 2 — runtime error (hard) + +`resolveSingleEntityAlias` (`location.service.ts:359`) currently queries `findAliasEntities(aliasInfo, 1)` and takes `page.data[0]`, which is precisely what makes over-resolution invisible. Change it to request **pageSize 2** and branch on the count: + +| Resolved count | Outcome | +|---|---| +| 0 | existing `error-alias-not-resolved` | +| 1 | proceed | +| > 1 | new `error-alias-multiple`, reporting the count | + +Count as `max(page.totalElements ?? 0, page.data.length)` so the message reports the true total rather than the page size when the server supplies it. + +Two notes on `findAliasEntities` (`:286`): the page size only affects its `resolveMultiple === true` branch — a non-multiple alias short-circuits to `aliasInfo.currentEntity` as a one-element page and can never trip the new error. And with `resolveEntityAliasTargets` deleted it drops to a single caller, so the `pageSize` parameter may be inlined as a constant if the implementer prefers. + +Rename the method to `resolveEntityAlias` — with fan-out gone, "single" is no longer a distinguishing qualifier. + +This one change covers all three actions, because every path now funnels through `resolveTargetEntity` (`:317`). + +## Code changes + +### `ui-ngx/src/app/core/services/location.service.ts` + +**Delete:** + +- `LocationTargetResolution` interface (`:68-71`) +- `resolveTargets` (`:240-251`) and its `resolveNames` parameter +- `resolveEntityAliasTargets` (`:253-273`) +- `saveToTargets` (`:429-443`) +- `aliasTargetsPageSize` (`:74`) and `saveConcurrency` (`:77`) +- the now-unused `from` / `mergeMap` / `toArray` imports if nothing else needs them + +**Rewire the two savers** to `resolveTargetEntity` → `saveKeys` (`:445`), which already writes attributes and time series for one entity: + +- `saveMobileActionLocation` (`:95`) keeps `resolveTargetEntityName` (`:373`) — `LiveTrackingSaveInfo.targetName` needs it — and its `targetName` becomes that single name instead of a joined list. +- `saveBrowserLocation` (`:125`) never needed the name (it passed `resolveNames = false`), so it calls `resolveTargetEntity` → `saveKeys` and skips name resolution entirely. + +**Simplify the browser toasts** (`:156-181`): the partial and multi branches go away, leaving `location-saved-keys` when keys were written and `location-saved` otherwise. A failed save keeps its existing error toast. + +**`liveTrackingArgs` (`:186`)** inherits the new error for free — it already calls `resolveTargetEntity`, so its previous silent alphabetically-first pick is fixed as a side effect. But it does need a `catchError` of its own, because otherwise the error would be **invisible**. See "The `startLiveLocation` error sink" below. + +### `location-target-entity.component.ts` / `.html` + +- Add the config-time warning described above. +- **Remove the `panelHint` input** (`.ts:114-115`) and the `@if (panelHint)` branch in the template (`.html:20-24`). Its only caller was the live-tracking hint, which this design deletes; the panel title collapses to the unconditional form. +- Split the double-used title/label key (see Labels). + +### `mobile-action-editor.component.html` + +- Drop `panelHint="..."` from the `startLiveLocation` usage (`:85`). Nothing else changes here — the toggle (`:68`) and both keys-table titles (`:75`, `:88`) keep their current keys and text. + +### `save-browser-location-action-editor.component.html` + +- No changes. Its keys-table title (`:21`) is unaffected, and it never passed `panelHint`. + +## Labels + +**Scope: duplication only.** Once saving is single-entity, *"to entity"* is no longer factually wrong — it becomes accurate. So the wordiness-driven renames are dropped: **`save-to-entity` ("Save location to entity") and `location.saved-keys` ("Keys that are saved to entity") stay exactly as they are.** + +The genuine defect is the duplication: the panel title and the source row label are the *same* i18n key, `widget-action.mobile.target-entity-type`, rendered at `location-target-entity.component.html:21`/`:23` and again at `:36-37`. Splitting it needs two distinct strings, so both sides of the split are in scope. The `CURRENT_ENTITY` dropdown option is also renamed, for accuracy rather than brevity (see below). + +| Element | Key | Now | New | +|---|---|---|---| +| Save toggle (`getLocation`) | `widget-action.mobile.save-to-entity` | Save location to entity | *unchanged* | +| Panel title | *new:* `widget-action.mobile.target-panel-title` | — | **Target** | +| Source row label | `target-entity-type` → *rename to* `target-save-to` | Target entity | **Save to** | +| Source dropdown option | `widget-action.mobile.target-current-entity` | Current datasource | **Entity from widget datasource** | +| Keys table title | `widget-action.location.saved-keys` | Keys that are saved to entity | *unchanged* | + +Renaming the key `target-entity-type` → `target-save-to` is a two-line change (the key is referenced only in that one template, never in `location.models.ts`) and avoids leaving a key named `-entity-type` holding the text "Save to". + +Resulting panel for *Get phone location*: + +``` +Save location to entity ⏺ + Target [ Entity | From attribute ] + Save to [ Entity from widget datasource ▾ ] + Alias name [ … ] ← warning here when alias is set-valued + ┌ Keys that are saved to entity ─────┐ + │ Argument │ Data key │ Type │ +``` + +In *From attribute* mode the row label continues to switch to `target-attribute-source` ("Read attribute from"), unchanged. + +### Why "Entity from widget datasource" for `CURRENT_ENTITY` + +`"Current datasource"` had two problems: `"current"` never said *whose* datasource on a dashboard full of widgets, and it named a *datasource* when the value is an *entity* — a datasource is a query that yields entities, not an entity itself. + +The new label is accurate on every dispatch path, all of which take the entity from the widget's own datasource data: + +- **Row-level actions** pass the clicked row's own `entity.id` straight into `handleWidgetAction` as `entityId` (`entities-table-widget.component.ts:804-808`, plus the timeseries-table cell and flot data-point equivalents), which becomes `currentEntityId` in the resolver. +- **Widget-level actions** (header action, widget click) go through `getActiveEntityInfo()` (`widget.component.ts:1793`), which reads `widgetContext.activeEntityInfo` — assigned in exactly one place in the tree, `timeseries-table-widget.component.ts:645`, from `this.sources[this.sourceIndex].datasource`, i.e. the currently selected datasource tab — and otherwise falls back to `subscription.getFirstEntityInfo()`. + +It also parallels the naming of the existing `stateEntity` alias filter, `"Entity from dashboard state"`. + +Alternatives considered and rejected: + +- **"Entity from dashboard state"** — already taken, by an unrelated mechanism: `alias.filter-type-state-entity` (*"Entity taken from dashboard state parameters"*, `AliasFilterType.stateEntity`). `CURRENT_ENTITY` never reads state params. +- **"Current entity"** — matches `calculated-fields.argument-current` and the existing *"Widget action has no current entity"* error string, but keeps the vague `"current"` qualifier. +- **"Widget datasource"** — shorter and accurate about *whose* datasource, but still names a datasource when the value is an entity. + +## Localization + +`locale.constant-en_US.json` **only**. Verified: no other `locale.constant-*.json` contains `target-current-entity`, so none of these keys have been translated yet. + +**Add:** + +- `widget-action.location.error-alias-multiple` — *"Entity alias '{{alias}}' resolves to {{count}} entities. The location can only be saved to a single entity."* +- `widget-action.mobile.target-alias-multiple-warning` — *"This alias can resolve to multiple entities. Saving will fail unless it resolves to exactly one."* +- `widget-action.mobile.target-panel-title` — *"Target"* +- `widget-action.mobile.live-location-start-failed` — *"Failed to start live location tracking: {{error}}"*. A new key rather than reusing `location-save-failed` ("Failed to save location: …"), which would misdescribe a start failure. + +**Rename:** `widget-action.mobile.target-entity-type` → `widget-action.mobile.target-save-to`, new value *"Save to"*. Its three template references (`location-target-entity.component.html:21`, `:23`, `:37`) collapse to one: `:21`/`:23` are the two `panelHint` branches that this design merges into a single unconditional title using the new `target-panel-title` key, leaving `:37` as the only `target-save-to` use. + +**Change in place:** `widget-action.mobile.target-current-entity` → *"Entity from widget datasource"*. `save-to-entity` and `location.saved-keys` are deliberately left alone (see Labels). + +**Remove:** + +- `widget-action.browser-location.location-saved-keys-multi` +- `widget-action.browser-location.location-saved-partial` +- `widget-action.mobile.live-location-alias-hint` — asserted *"…uses the first one"*, which this design replaces with an error + +## Mobile app impact + +**None.** The Flutter app already models exactly one target: `LiveTrackingConfig.target` is a single `LiveTrackingTarget` (`lib/utils/services/live_location_tracking/model/live_tracking_config.dart:122-179`), and `_save` writes one telemetry and one attributes request per fix (`live_location_tracking_service.dart:310-339`). The web side already sends a single `target: {entityType, id}` (`location.service.ts:191-194`). This design makes the web editor and the one-shot savers consistent with what the app already does — the wire protocol is unchanged. + +## Error handling + +| Condition | Message | Surface | +|---|---|---| +| Alias not present on dashboard | `error-alias-not-found` (existing) | error toast | +| Alias resolves to 0 | `error-alias-not-resolved` (existing) | error toast | +| Alias resolves to >1 | `error-alias-multiple` (**new**) | error toast | +| `CURRENT_ENTITY` with no active entity | `error-no-current-entity` (existing) | error toast | +| Attribute-source problems | existing `error-attribute-*` family | error toast | +| Save request fails | `location-save-failed` (existing, per action) | error toast | +| Any of the above while starting live tracking | `live-location-start-failed` (**new**) wrapping the cause | error toast | + +`saveMobileActionLocation` catches into a toast and completes without emitting; `saveBrowserLocation` reports through its `subscribe` error handler. Both are unchanged. + +### The `startLiveLocation` error sink + +`startLiveLocation` is the only action that resolves its target **during** bridge-args building rather than after, and that phase's error sink discards everything. An uncaught error propagates to the `error` callback of `argsObservable.subscribe(...)` (`widget.component.ts:1496`), which wraps it as `` `Failed to get mobile action arguments: ${err.message}` `` and hands it to `handleWidgetMobileActionError` (`:1509`) — a method whose entire body is one `if (isNotEmptyTbFunction(mobileAction.handleErrorFunction))` with no `else`. With no custom *Handle error* function configured on the action, the message is **dropped**: no toast, no `console.error`, no session. The button appears dead. + +That silence predates this design — it already swallows `error-alias-not-found`, `error-alias-not-resolved`, `error-no-current-entity` and the whole `error-attribute-*` family for live tracking. But making over-resolution a hard error adds a failure mode that is easy to hit by accident, which would convert the old *silently wrong* behaviour (tracking the alphabetically first entity) into *silently nothing* — strictly worse for the user, and the opposite of this design's goal. + +The config-time warning does not cover it: that shows to whoever edits the dashboard, while the person tapping the button in the mobile app is often someone else. + +**Fix:** give `liveTrackingArgs` the same treatment the other two savers already have — a trailing `catchError` that shows `live-location-start-failed` via `ctx.showErrorToast` and returns `EMPTY`, so the observable completes without emitting, `next` never fires, and no bridge call is made. All three actions then surface one clean localized toast, and the four pre-existing silent conditions are closed as a side effect. + +## Testing + +`ui-ngx` has one `.spec.ts` in the entire tree (`auth.service.spec.ts`), so unit tests are not the convention here. Verify manually, in the style of `docs/testing/`: + +**Per action** — `getLocation` + *Save location*, `saveBrowserLocation`, `startLiveLocation`: + +1. `Entity from widget datasource` on an entity widget → saves to the widget's active/first datasource entity; on a table row action, to the clicked row's entity. +2. `Current user` → saves to the user entity. +3. Alias with `resolveMultiple: false` → saves to the one entity, no warning shown. +4. Alias with `resolveMultiple: true` resolving to exactly **1** → **warning shown in editor, save succeeds**. This is the case that must not regress into a block. +5. Alias with `resolveMultiple: true` resolving to **many** → warning in editor; at click time an error toast naming the alias and the count; **no writes land on any entity**. +6. `From attribute` with a set-valued alias as the attribute source → same warning, same runtime error. + +**Editor:** panel title and source row read differently ("Target" / "Save to") in all three editors; no live-tracking tooltip on the panel title for `startLiveLocation`. + +**Regression:** an action configured before this change (target + keys already stored) still loads and saves — the stored `MobileActionTargetEntityConfig` shape is untouched. + +## Out of scope + +- **Explicit multi-target save** (a small, deliberately named target list, capped low). The plausible co-move use case; revisit only on a concrete request. +- **Multi-entity live tracking** — remains out of scope for the reasons in `2026-07-22-gps-live-tracking-ux-design.md:17`, now reinforced: 2N requests per fix for the whole session duration, and contested `GPS_ACTIVE` / `GPS_TRACKED_BY` flags between overlapping sessions. +- **Translating the new strings** into the other 27 locale files. +- **Mid-session alias re-resolution.** diff --git a/ios/Runner/Info-Debug.plist b/ios/Runner/Info-Debug.plist index 7bd29d92..316b91ac 100644 --- a/ios/Runner/Info-Debug.plist +++ b/ios/Runner/Info-Debug.plist @@ -68,6 +68,7 @@ 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..28b686be --- /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_page.dart'; + +class LocationTrackingRoutes { + static const liveTracking = '/liveTracking'; +} + +final List locationTrackingRoutes = [ + GoRoute( + path: LocationTrackingRoutes.liveTracking, + builder: (context, state) { + return const LiveTrackingPage(); + }, + ), +]; 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/constants/app_constants.dart b/lib/constants/app_constants.dart index 84f4b510..f4ba956c 100644 --- a/lib/constants/app_constants.dart +++ b/lib/constants/app_constants.dart @@ -18,8 +18,14 @@ abstract final class ThingsboardAppConstants { /// Dio `extra` flag telling the TB client to skip the global error overlay so /// the caller can handle the failure (e.g. a 429) inline. static const ignoreErrors = {'ignoreErrors': true}; - static final navigationType = - TbNavigationType.fromString( - const String.fromEnvironment('navigationType'), + + /// Dio `extra` flags for requests that run behind the user's back (periodic + /// live-tracking saves): no global error dialog, no loading indicator. + static const backgroundRequest = { + 'ignoreErrors': true, + 'ignoreLoading': true, + }; + static final navigationType = TbNavigationType.fromString( + const String.fromEnvironment('navigationType'), ); } diff --git a/lib/constants/database_keys.dart b/lib/constants/database_keys.dart index 856a2273..d1e306aa 100644 --- a/lib/constants/database_keys.dart +++ b/lib/constants/database_keys.dart @@ -2,4 +2,5 @@ abstract final class DatabaseKeys { static const thingsBoardApiEndpointKey = 'thingsBoardApiEndpoint'; static const initialAppLink = 'initialAppLink'; static const selectedRegion = 'selectedRegion'; + static const liveTrackingLastRecord = 'live_tracking_last_record'; } diff --git a/lib/core/auth/login/provider/login_provider.dart b/lib/core/auth/login/provider/login_provider.dart index f63e48ee..ef97041d 100644 --- a/lib/core/auth/login/provider/login_provider.dart +++ b/lib/core/auth/login/provider/login_provider.dart @@ -14,6 +14,8 @@ import 'package:thingsboard_app/utils/services/communication/events/user_loaded_ import 'package:thingsboard_app/utils/services/communication/i_communication_service.dart'; import 'package:thingsboard_app/utils/services/device_info/i_device_info_service.dart'; import 'package:thingsboard_app/utils/services/firebase/i_firebase_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; import 'package:thingsboard_app/utils/services/notification_service.dart'; import 'package:thingsboard_app/utils/services/overlay_service/i_overlay_service.dart'; import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; @@ -40,6 +42,15 @@ class Login extends _$Login { } Future logout() async { + // Tracking teardown writes gpsActive=false to the target entity, which + // needs the still-valid token — it must complete before the client logout + // invalidates it. A teardown failure must not keep the user logged in. + try { + await getIt().stop(); + await getIt().clear(); + } catch (e) { + log('logout: live tracking teardown failed: $e'); + } if (getIt().apps.isNotEmpty && state.isFullyAuthenticated()) { await getIt().logout(); diff --git a/lib/core/auth/login/provider/login_provider.g.dart b/lib/core/auth/login/provider/login_provider.g.dart index 6597a90f..51448841 100644 --- a/lib/core/auth/login/provider/login_provider.g.dart +++ b/lib/core/auth/login/provider/login_provider.g.dart @@ -6,7 +6,7 @@ part of 'login_provider.dart'; // RiverpodGenerator // ************************************************************************** -String _$loginHash() => r'6aea9a4e0e56ecd174fecf5836f1bfdf29c9a713'; +String _$loginHash() => r'154e055ae65de0266094443b5a2fc7af886eab62'; /// See also [Login]. @ProviderFor(Login) diff --git a/lib/core/context/tb_context.dart b/lib/core/context/tb_context.dart index 93cd0690..8814ffef 100644 --- a/lib/core/context/tb_context.dart +++ b/lib/core/context/tb_context.dart @@ -16,6 +16,8 @@ import 'package:thingsboard_app/utils/services/version_service/version_info.dart import 'package:thingsboard_app/utils/services/device_info/i_device_info_service.dart'; import 'package:thingsboard_app/utils/services/endpoint/i_endpoint_service.dart'; import 'package:thingsboard_app/utils/services/firebase/i_firebase_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; import 'package:thingsboard_app/utils/services/notification_service.dart'; import 'package:thingsboard_app/utils/services/overlay_service/i_overlay_service.dart'; import 'package:thingsboard_app/utils/utils.dart'; @@ -307,6 +309,9 @@ class TbContext implements PopEntry { await getIt().init(); } + await getIt().stop(); + await getIt().clear(); + await tbClient.logout(requestConfig: requestConfig, notifyUser: notifyUser); _appLinkStreamSubscription?.cancel(); diff --git a/lib/generated/intl/messages_en.dart b/lib/generated/intl/messages_en.dart index f36c4182..f8d134fc 100644 --- a/lib/generated/intl/messages_en.dart +++ b/lib/generated/intl/messages_en.dart @@ -478,6 +478,78 @@ class MessageLookup extends MessageLookupByLibrary { "listIsEmptyText": MessageLookupByLibrary.simpleMessage( "The list is currently empty.", ), + "liveTrackingActive": MessageLookupByLibrary.simpleMessage( + "Live location tracking", + ), + "liveTrackingDashboard": MessageLookupByLibrary.simpleMessage("Dashboard"), + "liveTrackingEndReason": MessageLookupByLibrary.simpleMessage("End reason"), + "liveTrackingEndReasonInterrupted": MessageLookupByLibrary.simpleMessage( + "Interrupted", + ), + "liveTrackingEndReasonManual": MessageLookupByLibrary.simpleMessage( + "Stopped manually", + ), + "liveTrackingEndReasonMaxDuration": MessageLookupByLibrary.simpleMessage( + "Reached max duration", + ), + "liveTrackingEnded": MessageLookupByLibrary.simpleMessage("End time"), + "liveTrackingErrorLocation": MessageLookupByLibrary.simpleMessage( + "Couldn\'t get a location fix.", + ), + "liveTrackingErrorNoConnection": MessageLookupByLibrary.simpleMessage( + "No connection to the server. These fixes weren\'t saved — saving resumes when you\'re back online.", + ), + "liveTrackingErrorPermissionDenied": MessageLookupByLibrary.simpleMessage( + "Location permission was denied.", + ), + "liveTrackingErrorPermissionDeniedForever": + MessageLookupByLibrary.simpleMessage( + "Location permission is permanently denied. Enable it in the app settings.", + ), + "liveTrackingErrorSaveFailed": MessageLookupByLibrary.simpleMessage( + "Couldn\'t save this fix to the server. Saving retries with the next fix.", + ), + "liveTrackingErrorServicesDisabled": MessageLookupByLibrary.simpleMessage( + "Location services are turned off on this device.", + ), + "liveTrackingErrorTargetNotFound": MessageLookupByLibrary.simpleMessage( + "Can\'t save to this entity — it no longer exists. Tracking continues; update the target in the dashboard action.", + ), + "liveTrackingErrorUnauthorized": MessageLookupByLibrary.simpleMessage( + "Your session ended. Sign in again to keep saving location.", + ), + "liveTrackingErrors": MessageLookupByLibrary.simpleMessage("Errors"), + "liveTrackingFixes": MessageLookupByLibrary.simpleMessage("Fixes"), + "liveTrackingHide": MessageLookupByLibrary.simpleMessage("Hide"), + "liveTrackingLastError": MessageLookupByLibrary.simpleMessage("Last error"), + "liveTrackingLastFix": MessageLookupByLibrary.simpleMessage("Last fix"), + "liveTrackingLastSession": MessageLookupByLibrary.simpleMessage( + "Last session", + ), + "liveTrackingMenuTitle": MessageLookupByLibrary.simpleMessage( + "Live location tracking", + ), + "liveTrackingNoRecord": MessageLookupByLibrary.simpleMessage( + "No active tracking and no recent session.", + ), + "liveTrackingPause": MessageLookupByLibrary.simpleMessage("Pause"), + "liveTrackingPaused": MessageLookupByLibrary.simpleMessage( + "Live tracking paused", + ), + "liveTrackingResume": MessageLookupByLibrary.simpleMessage("Resume"), + "liveTrackingSaved": MessageLookupByLibrary.simpleMessage("Saved"), + "liveTrackingSessionTitle": MessageLookupByLibrary.simpleMessage( + "Live location tracking", + ), + "liveTrackingStartAgain": MessageLookupByLibrary.simpleMessage( + "Start again", + ), + "liveTrackingStarted": MessageLookupByLibrary.simpleMessage("Start time"), + "liveTrackingStatus": MessageLookupByLibrary.simpleMessage("Status"), + "liveTrackingStop": MessageLookupByLibrary.simpleMessage("Stop"), + "liveTrackingTarget": MessageLookupByLibrary.simpleMessage( + "Save location to entity", + ), "login": MessageLookupByLibrary.simpleMessage("Log In"), "loginToApp": MessageLookupByLibrary.simpleMessage("Login to app"), "loginToYourAccount": MessageLookupByLibrary.simpleMessage( diff --git a/lib/generated/l10n.dart b/lib/generated/l10n.dart index 41eec96a..8c6817a9 100644 --- a/lib/generated/l10n.dart +++ b/lib/generated/l10n.dart @@ -3313,6 +3313,311 @@ class S { args: [count], ); } + + /// `Live location tracking` + String get liveTrackingActive { + return Intl.message( + 'Live location tracking', + name: 'liveTrackingActive', + desc: '', + args: [], + ); + } + + /// `Live tracking paused` + String get liveTrackingPaused { + return Intl.message( + 'Live tracking paused', + name: 'liveTrackingPaused', + desc: '', + args: [], + ); + } + + /// `Fixes` + String get liveTrackingFixes { + return Intl.message('Fixes', name: 'liveTrackingFixes', desc: '', args: []); + } + + /// `Saved` + String get liveTrackingSaved { + return Intl.message('Saved', name: 'liveTrackingSaved', desc: '', args: []); + } + + /// `Errors` + String get liveTrackingErrors { + return Intl.message( + 'Errors', + name: 'liveTrackingErrors', + desc: '', + args: [], + ); + } + + /// `Stop` + String get liveTrackingStop { + return Intl.message('Stop', name: 'liveTrackingStop', desc: '', args: []); + } + + /// `Pause` + String get liveTrackingPause { + return Intl.message('Pause', name: 'liveTrackingPause', desc: '', args: []); + } + + /// `Resume` + String get liveTrackingResume { + return Intl.message( + 'Resume', + name: 'liveTrackingResume', + desc: '', + args: [], + ); + } + + /// `Hide` + String get liveTrackingHide { + return Intl.message('Hide', name: 'liveTrackingHide', desc: '', args: []); + } + + /// `Live location tracking` + String get liveTrackingSessionTitle { + return Intl.message( + 'Live location tracking', + name: 'liveTrackingSessionTitle', + desc: '', + args: [], + ); + } + + /// `Save location to entity` + String get liveTrackingTarget { + return Intl.message( + 'Save location to entity', + name: 'liveTrackingTarget', + desc: '', + args: [], + ); + } + + /// `Dashboard` + String get liveTrackingDashboard { + return Intl.message( + 'Dashboard', + name: 'liveTrackingDashboard', + desc: '', + args: [], + ); + } + + /// `Status` + String get liveTrackingStatus { + return Intl.message( + 'Status', + name: 'liveTrackingStatus', + desc: '', + args: [], + ); + } + + /// `Start time` + String get liveTrackingStarted { + return Intl.message( + 'Start time', + name: 'liveTrackingStarted', + desc: '', + args: [], + ); + } + + /// `Last fix` + String get liveTrackingLastFix { + return Intl.message( + 'Last fix', + name: 'liveTrackingLastFix', + desc: '', + args: [], + ); + } + + /// `Last error` + String get liveTrackingLastError { + return Intl.message( + 'Last error', + name: 'liveTrackingLastError', + desc: '', + args: [], + ); + } + + /// `Can't save to this entity — it no longer exists. Tracking continues; update the target in the dashboard action.` + String get liveTrackingErrorTargetNotFound { + return Intl.message( + 'Can\'t save to this entity — it no longer exists. Tracking continues; update the target in the dashboard action.', + name: 'liveTrackingErrorTargetNotFound', + desc: '', + args: [], + ); + } + + /// `No connection to the server. These fixes weren't saved — saving resumes when you're back online.` + String get liveTrackingErrorNoConnection { + return Intl.message( + 'No connection to the server. These fixes weren\'t saved — saving resumes when you\'re back online.', + name: 'liveTrackingErrorNoConnection', + desc: '', + args: [], + ); + } + + /// `Your session ended. Sign in again to keep saving location.` + String get liveTrackingErrorUnauthorized { + return Intl.message( + 'Your session ended. Sign in again to keep saving location.', + name: 'liveTrackingErrorUnauthorized', + desc: '', + args: [], + ); + } + + /// `Couldn't save this fix to the server. Saving retries with the next fix.` + String get liveTrackingErrorSaveFailed { + return Intl.message( + 'Couldn\'t save this fix to the server. Saving retries with the next fix.', + name: 'liveTrackingErrorSaveFailed', + desc: '', + args: [], + ); + } + + /// `Location services are turned off on this device.` + String get liveTrackingErrorServicesDisabled { + return Intl.message( + 'Location services are turned off on this device.', + name: 'liveTrackingErrorServicesDisabled', + desc: '', + args: [], + ); + } + + /// `Location permission was denied.` + String get liveTrackingErrorPermissionDenied { + return Intl.message( + 'Location permission was denied.', + name: 'liveTrackingErrorPermissionDenied', + desc: '', + args: [], + ); + } + + /// `Location permission is permanently denied. Enable it in the app settings.` + String get liveTrackingErrorPermissionDeniedForever { + return Intl.message( + 'Location permission is permanently denied. Enable it in the app settings.', + name: 'liveTrackingErrorPermissionDeniedForever', + desc: '', + args: [], + ); + } + + /// `Couldn't get a location fix.` + String get liveTrackingErrorLocation { + return Intl.message( + 'Couldn\'t get a location fix.', + name: 'liveTrackingErrorLocation', + desc: '', + args: [], + ); + } + + /// `Live location tracking` + String get liveTrackingMenuTitle { + return Intl.message( + 'Live location tracking', + name: 'liveTrackingMenuTitle', + desc: '', + args: [], + ); + } + + /// `No active tracking and no recent session.` + String get liveTrackingNoRecord { + return Intl.message( + 'No active tracking and no recent session.', + name: 'liveTrackingNoRecord', + desc: '', + args: [], + ); + } + + /// `Last session` + String get liveTrackingLastSession { + return Intl.message( + 'Last session', + name: 'liveTrackingLastSession', + desc: '', + args: [], + ); + } + + /// `Start again` + String get liveTrackingStartAgain { + return Intl.message( + 'Start again', + name: 'liveTrackingStartAgain', + desc: '', + args: [], + ); + } + + /// `End time` + String get liveTrackingEnded { + return Intl.message( + 'End time', + name: 'liveTrackingEnded', + desc: '', + args: [], + ); + } + + /// `End reason` + String get liveTrackingEndReason { + return Intl.message( + 'End reason', + name: 'liveTrackingEndReason', + desc: '', + args: [], + ); + } + + /// `Stopped manually` + String get liveTrackingEndReasonManual { + return Intl.message( + 'Stopped manually', + name: 'liveTrackingEndReasonManual', + desc: '', + args: [], + ); + } + + /// `Reached max duration` + String get liveTrackingEndReasonMaxDuration { + return Intl.message( + 'Reached max duration', + name: 'liveTrackingEndReasonMaxDuration', + desc: '', + args: [], + ); + } + + /// `Interrupted` + String get liveTrackingEndReasonInterrupted { + return Intl.message( + 'Interrupted', + name: 'liveTrackingEndReasonInterrupted', + desc: '', + args: [], + ); + } } class AppLocalizationDelegate extends LocalizationsDelegate { diff --git a/lib/l10n/intl_en.arb b/lib/l10n/intl_en.arb index 66612d92..0bdd4b62 100644 --- a/lib/l10n/intl_en.arb +++ b/lib/l10n/intl_en.arb @@ -470,5 +470,38 @@ "type": "int" } } - } + }, + "liveTrackingActive": "Live location tracking", + "liveTrackingPaused": "Live tracking paused", + "liveTrackingFixes": "Fixes", + "liveTrackingSaved": "Saved", + "liveTrackingErrors": "Errors", + "liveTrackingStop": "Stop", + "liveTrackingPause": "Pause", + "liveTrackingResume": "Resume", + "liveTrackingHide": "Hide", + "liveTrackingSessionTitle": "Live location tracking", + "liveTrackingTarget": "Save location to entity", + "liveTrackingDashboard": "Dashboard", + "liveTrackingStatus": "Status", + "liveTrackingStarted": "Start time", + "liveTrackingLastFix": "Last fix", + "liveTrackingLastError": "Last error", + "liveTrackingErrorTargetNotFound": "Can't save to this entity — it no longer exists. Tracking continues; update the target in the dashboard action.", + "liveTrackingErrorNoConnection": "No connection to the server. These fixes weren't saved — saving resumes when you're back online.", + "liveTrackingErrorUnauthorized": "Your session ended. Sign in again to keep saving location.", + "liveTrackingErrorSaveFailed": "Couldn't save this fix to the server. Saving retries with the next fix.", + "liveTrackingErrorServicesDisabled": "Location services are turned off on this device.", + "liveTrackingErrorPermissionDenied": "Location permission was denied.", + "liveTrackingErrorPermissionDeniedForever": "Location permission is permanently denied. Enable it in the app settings.", + "liveTrackingErrorLocation": "Couldn't get a location fix.", + "liveTrackingMenuTitle": "Live location tracking", + "liveTrackingNoRecord": "No active tracking and no recent session.", + "liveTrackingLastSession": "Last session", + "liveTrackingStartAgain": "Start again", + "liveTrackingEnded": "End time", + "liveTrackingEndReason": "End reason", + "liveTrackingEndReasonManual": "Stopped manually", + "liveTrackingEndReasonMaxDuration": "Reached max duration", + "liveTrackingEndReasonInterrupted": "Interrupted" } \ No newline at end of file diff --git a/lib/locator.dart b/lib/locator.dart index 0fb8b408..4bc9f22c 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -14,10 +14,22 @@ import 'package:thingsboard_app/utils/services/firebase/firebase_service.dart'; import 'package:thingsboard_app/utils/services/firebase/i_firebase_service.dart'; import 'package:thingsboard_app/utils/services/layouts/i_layout_service.dart'; import 'package:thingsboard_app/utils/services/layouts/layout_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_notifications.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_remote.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_notifications.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_remote.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_store.dart'; import 'package:thingsboard_app/utils/services/loading_service/i_loading_service.dart'; 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'; @@ -51,9 +63,38 @@ Future setUpRootDependencies() async { () => EndpointService(databaseService: getIt()), ) ..registerLazySingleton(() => OverlayService()) - ..registerLazySingleton(() => TbImageGalleryService()) - ..registerLazySingleton(() => ThingsboardAppRouter(overlayService: getIt())) + ..registerLazySingleton( + () => TbImageGalleryService(), + ) + ..registerLazySingleton( + () => ThingsboardAppRouter(overlayService: getIt()), + ) ..registerLazySingleton(() => deviceInfoService) + ..registerLazySingleton( + () => LocationService(logger: getIt()), + ) + ..registerLazySingleton( + () => LiveTrackingRemote(clientService: getIt()), + ) + ..registerLazySingleton( + () => LiveTrackingStore(storage: getIt(), logger: getIt()), + ) + ..registerLazySingleton( + () => EntityNameResolver(clientService: getIt(), logger: getIt()), + ) + ..registerLazySingleton( + () => LiveTrackingNotifications(logger: getIt()), + ) + ..registerLazySingleton( + () => LiveLocationTrackingService( + locationService: getIt(), + remote: getIt(), + logger: getIt(), + store: getIt(), + nameResolver: getIt(), + notifications: getIt(), + ), + ) // ..registerLazySingleton(() => TbContext()) ..registerSingletonAsync(() async { final client = TbClientService(); diff --git a/lib/main.dart b/lib/main.dart index a0b7c9b4..8d6ed9ad 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:developer'; import 'package:app_links/app_links.dart'; @@ -16,6 +17,7 @@ import 'package:thingsboard_app/firebase_options.dart'; import 'package:thingsboard_app/locator.dart'; import 'package:thingsboard_app/thingsboard_app.dart'; import 'package:thingsboard_app/utils/services/firebase/i_firebase_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_notifications.dart'; import 'package:thingsboard_app/utils/services/local_database/i_local_database_service.dart'; import 'package:universal_platform/universal_platform.dart'; @@ -24,9 +26,12 @@ Future main() async { WidgetsFlutterBinding.ensureInitialized(); FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); await Hive.initFlutter(); - SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark); + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark); Hive.registerAdapter(RegionAdapter()); await setUpRootDependencies(); + // A paused-session notification survives process death while the session + // itself does not, so drop any leftover from a previous run. + unawaited(getIt().clear()); if (UniversalPlatform.isAndroid) { await InAppWebViewController.setWebContentsDebuggingEnabled( kDebugMode || EnvironmentVariables.verbose, diff --git a/lib/modules/location_tracking/presentation/provider/live_tracking_provider.dart b/lib/modules/location_tracking/presentation/provider/live_tracking_provider.dart new file mode 100644 index 00000000..b1121b0f --- /dev/null +++ b/lib/modules/location_tracking/presentation/provider/live_tracking_provider.dart @@ -0,0 +1,65 @@ +import 'dart:async'; + +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; + +part 'live_tracking_provider.freezed.dart'; +part 'live_tracking_provider.g.dart'; + +@freezed +abstract class LiveTrackingViewState with _$LiveTrackingViewState { + const factory LiveTrackingViewState({ + LiveTrackingSession? session, + @Default(false) bool hidden, + }) = _LiveTrackingViewState; +} + +@riverpod +class LiveTracking extends _$LiveTracking { + late final StreamSubscription _listener; + + @override + LiveTrackingViewState build() { + final service = getIt(); + _listener = service.sessionStream.listen((session) { + state = LiveTrackingViewState( + session: session, + hidden: session != null && state.hidden, + ); + }); + ref.onDispose(() => _listener.cancel()); + return LiveTrackingViewState(session: service.session); + } + + void hide() => state = state.copyWith(hidden: true); + + void show() => state = state.copyWith(hidden: false); + + Future stop() => getIt().stop(); + + Future pause() => getIt().pause(); + + Future resume() => getIt().resume(); + + Future startConfig(LiveTrackingConfig config) => + getIt().start(config); +} + +@riverpod +Future targetName( + Ref ref, { + required String entityType, + required String id, +}) => getIt().resolveName(entityType, id); + +@riverpod +Future lastRecord(Ref ref) => + getIt().read(); diff --git a/lib/modules/location_tracking/presentation/provider/live_tracking_provider.freezed.dart b/lib/modules/location_tracking/presentation/provider/live_tracking_provider.freezed.dart new file mode 100644 index 00000000..2100b135 --- /dev/null +++ b/lib/modules/location_tracking/presentation/provider/live_tracking_provider.freezed.dart @@ -0,0 +1,298 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'live_tracking_provider.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$LiveTrackingViewState { + + LiveTrackingSession? get session; bool get hidden; +/// Create a copy of LiveTrackingViewState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LiveTrackingViewStateCopyWith get copyWith => _$LiveTrackingViewStateCopyWithImpl(this as LiveTrackingViewState, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LiveTrackingViewState&&(identical(other.session, session) || other.session == session)&&(identical(other.hidden, hidden) || other.hidden == hidden)); +} + + +@override +int get hashCode => Object.hash(runtimeType,session,hidden); + +@override +String toString() { + return 'LiveTrackingViewState(session: $session, hidden: $hidden)'; +} + + +} + +/// @nodoc +abstract mixin class $LiveTrackingViewStateCopyWith<$Res> { + factory $LiveTrackingViewStateCopyWith(LiveTrackingViewState value, $Res Function(LiveTrackingViewState) _then) = _$LiveTrackingViewStateCopyWithImpl; +@useResult +$Res call({ + LiveTrackingSession? session, bool hidden +}); + + +$LiveTrackingSessionCopyWith<$Res>? get session; + +} +/// @nodoc +class _$LiveTrackingViewStateCopyWithImpl<$Res> + implements $LiveTrackingViewStateCopyWith<$Res> { + _$LiveTrackingViewStateCopyWithImpl(this._self, this._then); + + final LiveTrackingViewState _self; + final $Res Function(LiveTrackingViewState) _then; + +/// Create a copy of LiveTrackingViewState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? session = freezed,Object? hidden = null,}) { + return _then(_self.copyWith( +session: freezed == session ? _self.session : session // ignore: cast_nullable_to_non_nullable +as LiveTrackingSession?,hidden: null == hidden ? _self.hidden : hidden // ignore: cast_nullable_to_non_nullable +as bool, + )); +} +/// Create a copy of LiveTrackingViewState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LiveTrackingSessionCopyWith<$Res>? get session { + if (_self.session == null) { + return null; + } + + return $LiveTrackingSessionCopyWith<$Res>(_self.session!, (value) { + return _then(_self.copyWith(session: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [LiveTrackingViewState]. +extension LiveTrackingViewStatePatterns on LiveTrackingViewState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LiveTrackingViewState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LiveTrackingViewState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LiveTrackingViewState value) $default,){ +final _that = this; +switch (_that) { +case _LiveTrackingViewState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LiveTrackingViewState value)? $default,){ +final _that = this; +switch (_that) { +case _LiveTrackingViewState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( LiveTrackingSession? session, bool hidden)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LiveTrackingViewState() when $default != null: +return $default(_that.session,_that.hidden);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( LiveTrackingSession? session, bool hidden) $default,) {final _that = this; +switch (_that) { +case _LiveTrackingViewState(): +return $default(_that.session,_that.hidden);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( LiveTrackingSession? session, bool hidden)? $default,) {final _that = this; +switch (_that) { +case _LiveTrackingViewState() when $default != null: +return $default(_that.session,_that.hidden);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _LiveTrackingViewState implements LiveTrackingViewState { + const _LiveTrackingViewState({this.session, this.hidden = false}); + + +@override final LiveTrackingSession? session; +@override@JsonKey() final bool hidden; + +/// Create a copy of LiveTrackingViewState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LiveTrackingViewStateCopyWith<_LiveTrackingViewState> get copyWith => __$LiveTrackingViewStateCopyWithImpl<_LiveTrackingViewState>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LiveTrackingViewState&&(identical(other.session, session) || other.session == session)&&(identical(other.hidden, hidden) || other.hidden == hidden)); +} + + +@override +int get hashCode => Object.hash(runtimeType,session,hidden); + +@override +String toString() { + return 'LiveTrackingViewState(session: $session, hidden: $hidden)'; +} + + +} + +/// @nodoc +abstract mixin class _$LiveTrackingViewStateCopyWith<$Res> implements $LiveTrackingViewStateCopyWith<$Res> { + factory _$LiveTrackingViewStateCopyWith(_LiveTrackingViewState value, $Res Function(_LiveTrackingViewState) _then) = __$LiveTrackingViewStateCopyWithImpl; +@override @useResult +$Res call({ + LiveTrackingSession? session, bool hidden +}); + + +@override $LiveTrackingSessionCopyWith<$Res>? get session; + +} +/// @nodoc +class __$LiveTrackingViewStateCopyWithImpl<$Res> + implements _$LiveTrackingViewStateCopyWith<$Res> { + __$LiveTrackingViewStateCopyWithImpl(this._self, this._then); + + final _LiveTrackingViewState _self; + final $Res Function(_LiveTrackingViewState) _then; + +/// Create a copy of LiveTrackingViewState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? session = freezed,Object? hidden = null,}) { + return _then(_LiveTrackingViewState( +session: freezed == session ? _self.session : session // ignore: cast_nullable_to_non_nullable +as LiveTrackingSession?,hidden: null == hidden ? _self.hidden : hidden // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +/// Create a copy of LiveTrackingViewState +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$LiveTrackingSessionCopyWith<$Res>? get session { + if (_self.session == null) { + return null; + } + + return $LiveTrackingSessionCopyWith<$Res>(_self.session!, (value) { + return _then(_self.copyWith(session: value)); + }); +} +} + +// dart format on diff --git a/lib/modules/location_tracking/presentation/provider/live_tracking_provider.g.dart b/lib/modules/location_tracking/presentation/provider/live_tracking_provider.g.dart new file mode 100644 index 00000000..d4534c88 --- /dev/null +++ b/lib/modules/location_tracking/presentation/provider/live_tracking_provider.g.dart @@ -0,0 +1,200 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'live_tracking_provider.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +String _$targetNameHash() => r'645fa4232e9bba656f144a65bb97fc41b9e49ce6'; + +/// Copied from Dart SDK +class _SystemHash { + _SystemHash._(); + + static int combine(int hash, int value) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + value); + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10)); + return hash ^ (hash >> 6); + } + + static int finish(int hash) { + // ignore: parameter_assignments + hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3)); + // ignore: parameter_assignments + hash = hash ^ (hash >> 11); + return 0x1fffffff & (hash + ((0x00003fff & hash) << 15)); + } +} + +/// See also [targetName]. +@ProviderFor(targetName) +const targetNameProvider = TargetNameFamily(); + +/// See also [targetName]. +class TargetNameFamily extends Family> { + /// See also [targetName]. + const TargetNameFamily(); + + /// See also [targetName]. + TargetNameProvider call({required String entityType, required String id}) { + return TargetNameProvider(entityType: entityType, id: id); + } + + @override + TargetNameProvider getProviderOverride( + covariant TargetNameProvider provider, + ) { + return call(entityType: provider.entityType, id: provider.id); + } + + static const Iterable? _dependencies = null; + + @override + Iterable? get dependencies => _dependencies; + + static const Iterable? _allTransitiveDependencies = null; + + @override + Iterable? get allTransitiveDependencies => + _allTransitiveDependencies; + + @override + String? get name => r'targetNameProvider'; +} + +/// See also [targetName]. +class TargetNameProvider extends AutoDisposeFutureProvider { + /// See also [targetName]. + TargetNameProvider({required String entityType, required String id}) + : this._internal( + (ref) => + targetName(ref as TargetNameRef, entityType: entityType, id: id), + from: targetNameProvider, + name: r'targetNameProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$targetNameHash, + dependencies: TargetNameFamily._dependencies, + allTransitiveDependencies: TargetNameFamily._allTransitiveDependencies, + entityType: entityType, + id: id, + ); + + TargetNameProvider._internal( + super._createNotifier, { + required super.name, + required super.dependencies, + required super.allTransitiveDependencies, + required super.debugGetCreateSourceHash, + required super.from, + required this.entityType, + required this.id, + }) : super.internal(); + + final String entityType; + final String id; + + @override + Override overrideWith( + FutureOr Function(TargetNameRef provider) create, + ) { + return ProviderOverride( + origin: this, + override: TargetNameProvider._internal( + (ref) => create(ref as TargetNameRef), + from: from, + name: null, + dependencies: null, + allTransitiveDependencies: null, + debugGetCreateSourceHash: null, + entityType: entityType, + id: id, + ), + ); + } + + @override + AutoDisposeFutureProviderElement createElement() { + return _TargetNameProviderElement(this); + } + + @override + bool operator ==(Object other) { + return other is TargetNameProvider && + other.entityType == entityType && + other.id == id; + } + + @override + int get hashCode { + var hash = _SystemHash.combine(0, runtimeType.hashCode); + hash = _SystemHash.combine(hash, entityType.hashCode); + hash = _SystemHash.combine(hash, id.hashCode); + + return _SystemHash.finish(hash); + } +} + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +mixin TargetNameRef on AutoDisposeFutureProviderRef { + /// The parameter `entityType` of this provider. + String get entityType; + + /// The parameter `id` of this provider. + String get id; +} + +class _TargetNameProviderElement + extends AutoDisposeFutureProviderElement + with TargetNameRef { + _TargetNameProviderElement(super.provider); + + @override + String get entityType => (origin as TargetNameProvider).entityType; + @override + String get id => (origin as TargetNameProvider).id; +} + +String _$lastRecordHash() => r'648394a8af4909026aa63b36d9e48965560bf5da'; + +/// See also [lastRecord]. +@ProviderFor(lastRecord) +final lastRecordProvider = + AutoDisposeFutureProvider.internal( + lastRecord, + name: r'lastRecordProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$lastRecordHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +@Deprecated('Will be removed in 3.0. Use Ref instead') +// ignore: unused_element +typedef LastRecordRef = AutoDisposeFutureProviderRef; +String _$liveTrackingHash() => r'bd053f24dfe54ab8514d60e1cda51896cea7d15b'; + +/// See also [LiveTracking]. +@ProviderFor(LiveTracking) +final liveTrackingProvider = + AutoDisposeNotifierProvider.internal( + LiveTracking.new, + name: r'liveTrackingProvider', + debugGetCreateSourceHash: + const bool.fromEnvironment('dart.vm.product') + ? null + : _$liveTrackingHash, + dependencies: null, + allTransitiveDependencies: null, + ); + +typedef _$LiveTracking = AutoDisposeNotifier; +// ignore_for_file: type=lint +// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package diff --git a/lib/modules/location_tracking/presentation/view/live_tracking_page.dart b/lib/modules/location_tracking/presentation/view/live_tracking_page.dart new file mode 100644 index 00000000..22021513 --- /dev/null +++ b/lib/modules/location_tracking/presentation/view/live_tracking_page.dart @@ -0,0 +1,381 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:intl/intl.dart'; +import 'package:thingsboard_app/config/routes/v2/routes_config/routes/asset_routes.dart'; +import 'package:thingsboard_app/config/routes/v2/routes_config/routes/customer_routes.dart'; +import 'package:thingsboard_app/config/routes/v2/routes_config/routes/dashboard_routes.dart'; +import 'package:thingsboard_app/config/themes/app_colors.dart'; +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/generated/l10n.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/modules/dashboard/domain/entites/dashboard_arguments.dart'; +import 'package:thingsboard_app/modules/location_tracking/presentation/provider/live_tracking_provider.dart'; +import 'package:thingsboard_app/utils/services/device_profile/device_profile_cache.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_display.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_error.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; +import 'package:thingsboard_app/utils/services/overlay_service/i_overlay_service.dart'; +import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; +import 'package:thingsboard_app/utils/utils.dart'; + +class LiveTrackingPage extends ConsumerWidget { + const LiveTrackingPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final session = ref.watch(liveTrackingProvider).session; + return Scaffold( + appBar: AppBar(title: Text(S.of(context).liveTrackingSessionTitle)), + body: session != null ? _ActiveSession(session: session) : _IdleView(), + ); + } +} + +final _sessionTimeFormat = DateFormat('yyyy-MM-dd HH:mm:ss'); + +String _formatSessionTime(DateTime time) => + _sessionTimeFormat.format(time.toLocal()); + +TextStyle _linkStyle(BuildContext context) { + final color = Theme.of(context).colorScheme.primary; + return TextStyle( + color: color, + decoration: TextDecoration.underline, + decorationColor: color, + ); +} + +String _errorLabel( + BuildContext context, + LiveTrackingError error, +) => switch (error) { + LiveTrackingError.targetNotFound => + S.of(context).liveTrackingErrorTargetNotFound, + LiveTrackingError.noConnection => S.of(context).liveTrackingErrorNoConnection, + LiveTrackingError.unauthorized => S.of(context).liveTrackingErrorUnauthorized, + LiveTrackingError.saveFailed => S.of(context).liveTrackingErrorSaveFailed, + LiveTrackingError.locationServicesDisabled => + S.of(context).liveTrackingErrorServicesDisabled, + LiveTrackingError.locationPermissionDenied => + S.of(context).liveTrackingErrorPermissionDenied, + LiveTrackingError.locationPermissionDeniedForever => + S.of(context).liveTrackingErrorPermissionDeniedForever, + LiveTrackingError.locationError => S.of(context).liveTrackingErrorLocation, +}; + +bool _targetHasDetailsPage(String entityType) => switch (entityType) { + 'DEVICE' || 'ASSET' || 'CUSTOMER' => true, + _ => false, +}; + +Future _openTargetEntity( + BuildContext context, + LiveTrackingTarget target, +) async { + switch (target.entityType) { + case 'ASSET': + context.push('${AssetRoutes.asset}/${target.id}'); + case 'CUSTOMER': + context.push( + '${CustomerRoutes.customers}${CustomerRoutes.customer}/${target.id}', + ); + case 'DEVICE': + await _openDeviceTarget(context, target.id); + } +} + +/// Mirrors the devices-list tap behavior: opens the dashboard configured in +/// the device profile, or warns a tenant admin that none is configured. +Future _openDeviceTarget(BuildContext context, String deviceId) async { + final tbClient = getIt().client; + try { + final device = + (await tbClient.getDeviceControllerApi().getDeviceById( + deviceId: deviceId, + )).data; + if (device == null) { + return; + } + final profile = await DeviceProfileCache.getDeviceProfileInfo( + tbClient, + device.type ?? '', + deviceId, + ); + final dashboardId = profile.info.defaultDashboardId?.id; + if (dashboardId == null) { + if (tbClient.isTenantAdmin()) { + getIt().showWarnNotification( + (context) => + S.of(context).mobileDashboardShouldBeConfiguredInDeviceProfile, + ); + } + return; + } + final state = Utils.createDashboardEntityState( + device.id, + entityName: device.name, + entityLabel: device.label, + ); + if (context.mounted) { + context.push( + DashboardRoutes.dashboard, + extra: DashboardArgumentsEntity( + id: dashboardId, + title: device.name, + state: state, + hideToolbar: false, + animate: false, + ), + ); + } + } catch (e, s) { + getIt().error('LiveTrackingPage: failed to open device', e, s); + } +} + +/// Tiles describing where fixes are saved: target entity (a link when its +/// type has a details page) and the dashboard the session was started from +/// (a link when its id is known). +List _saveConfigTiles( + BuildContext context, + LiveTrackingConfig config, + String targetName, +) { + final target = config.target; + final targetLinkable = _targetHasDetailsPage(target.entityType); + final dashboard = config.dashboard; + final dashboardId = dashboard?.id; + return [ + ListTile( + title: Text(S.of(context).liveTrackingTarget), + subtitle: Text( + targetName, + style: targetLinkable ? _linkStyle(context) : null, + ), + onTap: targetLinkable ? () => _openTargetEntity(context, target) : null, + ), + if (dashboard != null) + ListTile( + title: Text(S.of(context).liveTrackingDashboard), + subtitle: Text( + dashboard.title ?? dashboardId ?? '', + style: dashboardId != null ? _linkStyle(context) : null, + ), + onTap: + dashboardId != null + ? () => + context.push('${DashboardRoutes.dashboard}/$dashboardId') + : null, + ), + ]; +} + +class _ActiveSession extends ConsumerWidget { + const _ActiveSession({required this.session}); + + final LiveTrackingSession session; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final tracking = session.status == LiveTrackingStatus.tracking; + final lastFix = session.lastFix; + final target = session.config.target; + final nameAsync = ref.watch( + targetNameProvider(entityType: target.entityType, id: target.id), + ); + final name = displayTargetName( + nameAsync.valueOrNull ?? session.config.targetName, + target, + ); + final statusColor = + tracking + ? AppColors.notificationSuccess + : AppColors.notificationWarning; + return ListView( + children: [ + ..._saveConfigTiles(context, session.config, name), + ListTile( + title: Text(S.of(context).liveTrackingStatus), + subtitle: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + tracking ? Icons.play_arrow : Icons.pause, + size: 18, + color: statusColor, + ), + const SizedBox(width: 4), + Flexible( + child: Text( + tracking + ? S.of(context).liveTrackingActive + : S.of(context).liveTrackingPaused, + style: TextStyle(color: statusColor), + ), + ), + ], + ), + ), + ListTile( + title: Text(S.of(context).liveTrackingStarted), + subtitle: Text(_formatSessionTime(session.startedAt)), + ), + 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( + _errorLabel(context, session.lastError!), + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: () { + final notifier = ref.read(liveTrackingProvider.notifier); + tracking ? notifier.pause() : notifier.resume(); + }, + icon: Icon(tracking ? Icons.pause : Icons.play_arrow), + label: Text( + tracking + ? S.of(context).liveTrackingPause + : S.of(context).liveTrackingResume, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: FilledButton.icon( + onPressed: + () => ref.read(liveTrackingProvider.notifier).stop(), + icon: const Icon(Icons.stop), + label: Text(S.of(context).liveTrackingStop), + ), + ), + ], + ), + ), + ], + ); + } +} + +class _IdleView extends ConsumerWidget { + @override + Widget build(BuildContext context, WidgetRef ref) { + final recordAsync = ref.watch(lastRecordProvider); + return recordAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (_, _) => Center(child: Text(S.of(context).liveTrackingNoRecord)), + data: (record) { + if (record == null) { + return Center(child: Text(S.of(context).liveTrackingNoRecord)); + } + return _LastSession(record: record); + }, + ); + } +} + +class _LastSession extends ConsumerWidget { + const _LastSession({required this.record}); + + final LastTrackingRecord record; + + String _endReasonLabel(BuildContext context) => switch (record.endReason) { + TrackingEndReason.manual => S.of(context).liveTrackingEndReasonManual, + TrackingEndReason.maxDuration => + S.of(context).liveTrackingEndReasonMaxDuration, + TrackingEndReason.interrupted => + S.of(context).liveTrackingEndReasonInterrupted, + }; + + Future _startAgain(WidgetRef ref) async { + // AuthUser.sub is the current user's email (phase-1c trackedBy semantics); + // re-derive it so a relaunch is attributed to whoever is logged in now. + final email = getIt().client.getAuthUser()?.sub; + final config = LiveTrackingConfig.fromJson({ + ...record.configJson, + if (email != null) 'trackedBy': email, + }); + await ref.read(liveTrackingProvider.notifier).startConfig(config); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final config = record.config; + final name = displayTargetName( + record.targetName ?? config.targetName, + config.target, + ); + return ListView( + children: [ + ListTile( + title: Text( + S.of(context).liveTrackingLastSession, + style: Theme.of(context).textTheme.titleMedium, + ), + ), + ..._saveConfigTiles(context, config, name), + ListTile( + title: Text(S.of(context).liveTrackingStarted), + subtitle: Text(_formatSessionTime(record.startedAt)), + ), + if (record.endedAt != null) + ListTile( + title: Text(S.of(context).liveTrackingEnded), + subtitle: Text(_formatSessionTime(record.endedAt!)), + ), + ListTile( + title: Text(S.of(context).liveTrackingEndReason), + subtitle: Text(_endReasonLabel(context)), + ), + ListTile( + title: Text( + '${S.of(context).liveTrackingFixes}: ${record.fixCount} · ' + '${S.of(context).liveTrackingSaved}: ${record.savedCount} · ' + '${S.of(context).liveTrackingErrors}: ${record.saveErrorCount}', + ), + ), + if (record.lastLat != null && record.lastLng != null) + ListTile( + title: Text(S.of(context).liveTrackingLastFix), + subtitle: Text( + '${record.lastLat!.toStringAsFixed(6)}, ' + '${record.lastLng!.toStringAsFixed(6)}', + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: FilledButton.icon( + onPressed: () => _startAgain(ref), + icon: const Icon(Icons.play_arrow), + label: Text(S.of(context).liveTrackingStartAgain), + ), + ), + ], + ); + } +} diff --git a/lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart b/lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart new file mode 100644 index 00000000..1bf6acac --- /dev/null +++ b/lib/modules/location_tracking/presentation/widgets/live_tracking_bar.dart @@ -0,0 +1,142 @@ +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/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 _CollapsedBar(tracking: tracking); + } + + return Material( + color: colors.primaryContainer, + child: InkWell( + onTap: () => context.push(LocationTrackingRoutes.liveTracking), + 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(), + ), + ], + ), + ), + ), + ); + } +} + +class _CollapsedBar extends HookConsumerWidget { + const _CollapsedBar({required this.tracking}); + + final bool tracking; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colors = Theme.of(context).colorScheme; + final controller = useAnimationController( + duration: const Duration(milliseconds: 900), + ); + useEffect(() { + if (tracking) { + controller.repeat(reverse: true); + } else { + controller.stop(); + controller.value = 1; + } + return null; + }, [tracking]); + + return Material( + color: colors.primaryContainer, + child: InkWell( + onTap: () => ref.read(liveTrackingProvider.notifier).show(), + child: SizedBox( + width: double.infinity, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Center( + child: FadeTransition( + opacity: Tween(begin: 0.35, end: 1).animate(controller), + child: Icon( + tracking ? Icons.gps_fixed : Icons.gps_off, + size: 18, + color: colors.onPrimaryContainer, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/modules/main/navigation_page.dart b/lib/modules/main/navigation_page.dart index 060cb605..7fc85fcd 100644 --- a/lib/modules/main/navigation_page.dart +++ b/lib/modules/main/navigation_page.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:thingsboard_app/constants/app_constants.dart'; import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/modules/location_tracking/presentation/widgets/live_tracking_bar.dart'; import 'package:thingsboard_app/modules/main/model/main_navigation_item.dart'; import 'package:thingsboard_app/modules/main/model/navigation_type.dart'; import 'package:thingsboard_app/modules/main/providers/navigation_helper.dart'; @@ -61,7 +62,9 @@ class NavigationPage extends HookConsumerWidget { canPop: false, child: SafeArea( child: Scaffold( - body: child, + body: Column( + children: [const LiveTrackingBar(), Expanded(child: child)], + ), bottomNavigationBar: currentIndex.value == null ? null diff --git a/lib/modules/main/providers/navigation_helper.dart b/lib/modules/main/providers/navigation_helper.dart index 2514ad45..50df9dfc 100644 --- a/lib/modules/main/providers/navigation_helper.dart +++ b/lib/modules/main/providers/navigation_helper.dart @@ -33,14 +33,18 @@ class NavigationHelper { return s.deviceList; case 'dashboards': return s.dashboards(2); + case 'live_location_tracking': + return s.liveTrackingMenuTitle; } if (path == '/more') return s.more; if (path.contains('/profile')) return s.profile; return fallbackTitle; } -static int? getCurrentIndexFromPath(String path, List items) { - + static int? getCurrentIndexFromPath( + String path, + List items, + ) { for (int i = 0; i < items.length; i++) { if (items[i].path == path) { return i; @@ -49,111 +53,116 @@ static int? getCurrentIndexFromPath(String path, List items return null; } -// Helper functions -static String getLabel(PageLayout pageLayout) { - if (pageLayout.label != null) { - return pageLayout.label!; - } - - switch (pageLayout.id) { - case Pages.home: - return 'Home'; - case Pages.alarms: - return 'Alarms'; - case Pages.devices: - return 'Devices'; - case Pages.customers: - return 'Customers'; - case Pages.assets: - return 'Assets'; - case Pages.audit_logs: - return 'Audit Logs'; - case Pages.notifications: - return 'Notifications'; - case Pages.device_list: - return 'Device List'; - case Pages.dashboards: - return 'Dashboards'; - case Pages.undefined: - case null: - return pageLayout.label ?? '-'; - } -} + // Helper functions + static String getLabel(PageLayout pageLayout) { + if (pageLayout.label != null) { + return pageLayout.label!; + } -static IconData getIcon(PageLayout pageLayout) { - if (pageLayout.icon != null) { - return getIconFromString(pageLayout.icon); + switch (pageLayout.id) { + case Pages.home: + return 'Home'; + case Pages.alarms: + return 'Alarms'; + case Pages.devices: + return 'Devices'; + case Pages.customers: + return 'Customers'; + case Pages.assets: + return 'Assets'; + case Pages.audit_logs: + return 'Audit Logs'; + case Pages.notifications: + return 'Notifications'; + case Pages.device_list: + return 'Device List'; + case Pages.dashboards: + return 'Dashboards'; + case Pages.live_location_tracking: + return 'Live location tracking'; + case Pages.undefined: + case null: + return pageLayout.label ?? '-'; + } } - switch (pageLayout.id) { - case Pages.home: - return Icons.home_outlined; - case Pages.alarms: - return Icons.notifications_outlined; - case Pages.devices: - return Icons.devices_other_outlined; - case Pages.customers: - return Icons.supervisor_account_sharp; - case Pages.assets: - return Icons.domain_outlined; - case Pages.audit_logs: - return Icons.track_changes_outlined; - case Pages.notifications: - return Icons.notifications_active_sharp; - case Pages.device_list: - return Icons.devices; - case Pages.dashboards: - return Icons.dashboard_outlined; - case Pages.undefined: - case null: + static IconData getIcon(PageLayout pageLayout) { + if (pageLayout.icon != null) { return getIconFromString(pageLayout.icon); + } + + switch (pageLayout.id) { + case Pages.home: + return Icons.home_outlined; + case Pages.alarms: + return Icons.notifications_outlined; + case Pages.devices: + return Icons.devices_other_outlined; + case Pages.customers: + return Icons.supervisor_account_sharp; + case Pages.assets: + return Icons.domain_outlined; + case Pages.audit_logs: + return Icons.track_changes_outlined; + case Pages.notifications: + return Icons.notifications_active_sharp; + case Pages.device_list: + return Icons.devices; + case Pages.dashboards: + return Icons.dashboard_outlined; + case Pages.live_location_tracking: + return Icons.my_location; + case Pages.undefined: + case null: + return getIconFromString(pageLayout.icon); + } } -} -static String getPath(PageLayout pageLayout) { - switch (pageLayout.id) { - case Pages.home: - return '/home'; - case Pages.alarms: - return '/alarms'; - case Pages.devices: - return '/devices'; - case Pages.customers: - return '/customers'; - case Pages.assets: - return '/assets'; - case Pages.audit_logs: - return '/auditLogs'; - case Pages.notifications: - return '/notifications'; - case Pages.device_list: - return '/deviceList'; - case Pages.dashboards: - return '/dashboards'; - case Pages.undefined: - case null: - if (pageLayout.url != null) { - return '/url/${Uri.encodeComponent(pageLayout.url!)}'; - } else if (pageLayout.dashboardId != null) { - return '/dashboard/${pageLayout.dashboardId}'; - } else if (pageLayout.path?.startsWith('/url/') == true) { - return '/url/${Uri.encodeComponent(pageLayout.path!.split('/url/').last)}'; - } + static String getPath(PageLayout pageLayout) { + switch (pageLayout.id) { + case Pages.home: + return '/home'; + case Pages.alarms: + return '/alarms'; + case Pages.devices: + return '/devices'; + case Pages.customers: + return '/customers'; + case Pages.assets: + return '/assets'; + case Pages.audit_logs: + return '/auditLogs'; + case Pages.notifications: + return '/notifications'; + case Pages.device_list: + return '/deviceList'; + case Pages.dashboards: + return '/dashboards'; + case Pages.live_location_tracking: + return '/liveTracking'; + case Pages.undefined: + case null: + if (pageLayout.url != null) { + return '/url/${Uri.encodeComponent(pageLayout.url!)}'; + } else if (pageLayout.dashboardId != null) { + return '/dashboard/${pageLayout.dashboardId}'; + } else if (pageLayout.path?.startsWith('/url/') == true) { + return '/url/${Uri.encodeComponent(pageLayout.path!.split('/url/').last)}'; + } - return pageLayout.path ?? '/error'; + return pageLayout.path ?? '/error'; + } } -} -static IconData getIconFromString(String? icon) { - if (icon != null) { - if (icon.contains('mdi')) { - return MdiIcons.fromString(icon.split('mdi:').last) ?? - Icons.error_outline; - } + static IconData getIconFromString(String? icon) { + if (icon != null) { + if (icon.contains('mdi')) { + return MdiIcons.fromString(icon.split('mdi:').last) ?? + Icons.error_outline; + } - return materialIconsMap[icon] ?? Icons.error_outline; + return materialIconsMap[icon] ?? Icons.error_outline; + } + return Icons.error_outline; } - return Icons.error_outline; -} - } diff --git a/lib/modules/more/more_page.dart b/lib/modules/more/more_page.dart index 29d3a54b..22324053 100644 --- a/lib/modules/more/more_page.dart +++ b/lib/modules/more/more_page.dart @@ -45,40 +45,41 @@ 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(), + ), ), ), - ), - ], + ], Divider( color: Colors.black.withValues(alpha: .05), thickness: 1, @@ -92,8 +93,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/entity_query_api.dart b/lib/utils/services/entity_query_api.dart index 6f4318a6..b8d55553 100644 --- a/lib/utils/services/entity_query_api.dart +++ b/lib/utils/services/entity_query_api.dart @@ -125,6 +125,35 @@ abstract class EntityQueryApi { return response.data ?? 0; } + static EntityDataQuery createEntityNameQuery(String entityType, String id) { + return EntityDataQuery( + (b) => + b + ..entityFilter = SingleEntityFilter( + (b) => + b + ..type = 'singleEntity' + ..singleEntity = + AliasEntityId( + (b) => + b + ..entityType = EntityType.valueOf(entityType) + ..id = id, + ).toBuilder(), + ) + ..entityFields = + BuiltList([ + EntityKey( + (b) => + b + ..type = EntityKeyType.ENTITY_FIELD + ..key = 'name', + ), + ]).toBuilder() + ..pageLink = EntityDataPageLink((b) => b..pageSize = 1).toBuilder(), + ); + } + static EntityDataQuery createDefaultDeviceQuery({ int pageSize = 20, String? searchText, diff --git a/lib/utils/services/layouts/pages_layout.dart b/lib/utils/services/layouts/pages_layout.dart index e2395f64..665a2ce5 100644 --- a/lib/utils/services/layouts/pages_layout.dart +++ b/lib/utils/services/layouts/pages_layout.dart @@ -8,6 +8,7 @@ enum Pages { notifications, device_list, dashboards, + live_location_tracking, undefined, } diff --git a/lib/utils/services/live_location_tracking/entity_name_resolver.dart b/lib/utils/services/live_location_tracking/entity_name_resolver.dart new file mode 100644 index 00000000..5851a453 --- /dev/null +++ b/lib/utils/services/live_location_tracking/entity_name_resolver.dart @@ -0,0 +1,37 @@ +import 'package:thingsboard_app/constants/app_constants.dart'; +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/utils/services/entity_query_api.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; + +class EntityNameResolver implements IEntityNameResolver { + EntityNameResolver({ + required ITbClientService clientService, + required TbLogger logger, + }) : _clientService = clientService, + _log = logger; + + final ITbClientService _clientService; + final TbLogger _log; + + @override + Future resolveName(String entityType, String id) async { + try { + final query = EntityQueryApi.createEntityNameQuery(entityType, id); + final response = await _clientService.client + .getEntityQueryControllerApi() + .findEntityDataByQuery( + entityDataQuery: query, + extra: ThingsboardAppConstants.backgroundRequest, + ); + final data = response.data?.data; + if (data == null || data.isEmpty) { + return null; + } + return data.first.field('name'); + } catch (e, s) { + _log.error('EntityNameResolver.resolveName failed', e, s); + return null; + } + } +} diff --git a/lib/utils/services/live_location_tracking/i_entity_name_resolver.dart b/lib/utils/services/live_location_tracking/i_entity_name_resolver.dart new file mode 100644 index 00000000..4cba40f5 --- /dev/null +++ b/lib/utils/services/live_location_tracking/i_entity_name_resolver.dart @@ -0,0 +1,5 @@ +/// Resolves an entity's display name from its type + id. Returns null on any +/// failure (offline, deleted entity, unknown type) so callers fall back. +abstract interface class IEntityNameResolver { + Future resolveName(String entityType, String id); +} diff --git a/lib/utils/services/live_location_tracking/i_live_location_tracking_service.dart b/lib/utils/services/live_location_tracking/i_live_location_tracking_service.dart new file mode 100644 index 00000000..f44bd9b3 --- /dev/null +++ b/lib/utils/services/live_location_tracking/i_live_location_tracking_service.dart @@ -0,0 +1,23 @@ +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; + +/// App-wide owner of at most one live GPS tracking session. Runs the +/// location stream, saves telemetry/attributes per fix, and exposes session +/// state for the tracking bar / session screen. +abstract interface class ILiveLocationTrackingService { + LiveTrackingSession? get session; + + /// Emits on every session change; emits `null` when tracking stops. + Stream get sessionStream; + + /// Starts a session, replacing any active one. + Future start(LiveTrackingConfig config); + + Future stop(); + + /// Suspends position updates without discarding the session; writes + /// `gpsActive=false` so the platform sees data flow honestly stopped. + Future pause(); + + Future resume(); +} diff --git a/lib/utils/services/live_location_tracking/i_live_tracking_notifications.dart b/lib/utils/services/live_location_tracking/i_live_tracking_notifications.dart new file mode 100644 index 00000000..3480df97 --- /dev/null +++ b/lib/utils/services/live_location_tracking/i_live_tracking_notifications.dart @@ -0,0 +1,9 @@ +/// Syncs the "session is paused" system notification with the live tracking +/// session state. The tracking-active state needs no handling here: on Android +/// the geolocator foreground service shows its own notification while the GPS +/// stream is running, and iOS shows the system location indicator instead. +abstract interface class ILiveTrackingNotifications { + Future showPaused({String? targetName}); + + Future clear(); +} diff --git a/lib/utils/services/live_location_tracking/i_live_tracking_remote.dart b/lib/utils/services/live_location_tracking/i_live_tracking_remote.dart new file mode 100644 index 00000000..0dd833b2 --- /dev/null +++ b/lib/utils/services/live_location_tracking/i_live_tracking_remote.dart @@ -0,0 +1,19 @@ +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; + +/// Thin REST boundary for live tracking saves. Kept as an interface so the +/// tracking service is unit-testable and the CE/PE client difference stays +/// behind one seam. +abstract interface class ILiveTrackingRemote { + /// Saves one timeseries sample: `{ts, values}` on the target entity. + Future saveTelemetry( + LiveTrackingTarget target, + int ts, + Map values, + ); + + /// Saves SERVER_SCOPE attributes on the target entity. + Future saveAttributes( + LiveTrackingTarget target, + Map attributes, + ); +} diff --git a/lib/utils/services/live_location_tracking/i_live_tracking_store.dart b/lib/utils/services/live_location_tracking/i_live_tracking_store.dart new file mode 100644 index 00000000..75bd9194 --- /dev/null +++ b/lib/utils/services/live_location_tracking/i_live_tracking_store.dart @@ -0,0 +1,11 @@ +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; + +/// Persists the single most-recent tracking session so the idle page can show +/// and relaunch it. Device-global; cleared on logout. +abstract interface class ILiveTrackingStore { + Future read(); + + Future write(LastTrackingRecord record); + + Future clear(); +} diff --git a/lib/utils/services/live_location_tracking/live_location_tracking_service.dart b/lib/utils/services/live_location_tracking/live_location_tracking_service.dart new file mode 100644 index 00000000..011f7605 --- /dev/null +++ b/lib/utils/services/live_location_tracking/live_location_tracking_service.dart @@ -0,0 +1,345 @@ +import 'dart:async'; + +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_notifications.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_remote.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_error.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; +import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; +import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; +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, + required ILiveTrackingStore store, + required IEntityNameResolver nameResolver, + required ILiveTrackingNotifications notifications, + // Android notification strings are OS-level, set once at construction; + // English defaults are acceptable for v1 (the locator can later pass + // localized strings without touching this class). + this.backgroundConfig = const BackgroundTrackingConfig( + notificationTitle: 'ThingsBoard', + notificationText: 'Live location tracking is active', + ), + }) : _locationService = locationService, + _remote = remote, + _log = logger, + _store = store, + _nameResolver = nameResolver, + _notifications = notifications; + + final ILocationService _locationService; + final ILiveTrackingRemote _remote; + final TbLogger _log; + final ILiveTrackingStore _store; + final IEntityNameResolver _nameResolver; + final ILiveTrackingNotifications _notifications; + final BackgroundTrackingConfig backgroundConfig; + + final _sessionController = StreamController.broadcast(); + 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(); + final startedAt = DateTime.now(); + _setSession( + LiveTrackingSession( + config: config, + status: LiveTrackingStatus.tracking, + startedAt: startedAt, + ), + ); + // Persist the recoverable "interrupted" record and subscribe to GPS + // before doing anything network-bound: a force-kill or a race with + // stop()/logout must never leave this critical path gated on the + // (possibly slow or offline) entity-name lookup below. + await _store.write( + LastTrackingRecord( + configJson: config.toJson(), + targetName: config.targetName, + startedAt: startedAt, + endReason: TrackingEndReason.interrupted, + ), + ); + await _writeTrackingStatus(active: true, includeTrackedBy: true); + _subscribe(config); + final maxDuration = config.maxDurationSeconds; + if (maxDuration != null) { + _maxDurationTimer = Timer( + Duration(seconds: maxDuration), + () => _finish(TrackingEndReason.maxDuration), + ); + } + unawaited(_resolveAndPatchTargetName(config, startedAt)); + } + + /// Resolves the human-readable target name off the critical path and + /// patches it into the persisted record once known. Guarded against the + /// session having been stopped/replaced while the (network) lookup was in + /// flight, so a late resolution can never resurrect a stopped session's + /// record or re-subscribe GPS after stop()/logout. + Future _resolveAndPatchTargetName( + LiveTrackingConfig config, + DateTime startedAt, + ) async { + final name = await _nameResolver.resolveName( + config.target.entityType, + config.target.id, + ); + if (name == null) { + return; + } + if (_session?.startedAt != startedAt) { + return; + } + final existing = await _store.read(); + if (existing == null) { + return; + } + // Re-check after the read() await: stop()/logout may have finished (and + // cleared the store) while we were reading, and we must not resurrect a + // stopped session's record with a late write. + if (_session?.startedAt != startedAt) { + return; + } + await _store.write(existing.copyWith(targetName: name)); + } + + @override + Future stop() => _finish(TrackingEndReason.manual); + + Future _finish(TrackingEndReason reason) async { + _maxDurationTimer?.cancel(); + _maxDurationTimer = null; + _cancelSubscription(); + final current = _session; + if (current != null) { + await _notifications.clear(); + await _writeTrackingStatus(active: false); + await _updateRecordOnEnd(current, reason); + _setSession(null); + } + } + + Future _updateRecordOnEnd( + LiveTrackingSession session, + TrackingEndReason reason, + ) async { + final existing = await _store.read(); + if (existing == null) { + return; + } + await _store.write( + existing.copyWith( + endedAt: DateTime.now(), + fixCount: session.fixCount, + savedCount: session.savedCount, + saveErrorCount: session.saveErrorCount, + lastLat: session.lastFix?.latitude, + lastLng: session.lastFix?.longitude, + lastError: session.lastError?.name, + endReason: reason, + ), + ); + } + + @override + Future pause() async { + final current = _session; + if (current == null || current.status != LiveTrackingStatus.tracking) { + return; + } + _cancelSubscription(); + _setSession(current.copyWith(status: LiveTrackingStatus.paused)); + await _notifications.showPaused(targetName: current.config.targetName); + await _writeTrackingStatus(active: 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 _notifications.clear(); + await _writeTrackingStatus(active: true, includeTrackedBy: 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): + // A successful fix means the previously reported problem is over; + // a still-failing save below re-raises its own error. + _setSession( + current.copyWith( + fixCount: current.fixCount + 1, + lastFix: position, + lastError: null, + ), + ); + await _saveFix(current.config, position); + case LocationServicesDisabled(): + await _pauseWithError(LiveTrackingError.locationServicesDisabled); + case LocationPermissionDenied(): + await _pauseWithError(LiveTrackingError.locationPermissionDenied); + case LocationPermissionDeniedForever(): + await _pauseWithError( + LiveTrackingError.locationPermissionDeniedForever, + ); + case LocationFixError(): + _setSession( + _session?.copyWith(lastError: LiveTrackingError.locationError), + ); + } + } + + Future _saveFix(LiveTrackingConfig config, GeoPosition position) async { + final ts = (position.timestamp ?? DateTime.now()).millisecondsSinceEpoch; + try { + await _save(config, { + LiveTrackingKeyType.latitude: position.latitude, + LiveTrackingKeyType.longitude: position.longitude, + LiveTrackingKeyType.accuracy: position.accuracy, + LiveTrackingKeyType.altitude: position.altitude, + LiveTrackingKeyType.speed: position.speed, + LiveTrackingKeyType.heading: position.heading, + }, ts: ts); + final current = _session; + if (current != null) { + _setSession(current.copyWith(savedCount: current.savedCount + 1)); + } + } catch (e, s) { + _log.error('LiveLocationTrackingService: save failed', e, s); + final current = _session; + if (current != null) { + _setSession( + current.copyWith( + saveErrorCount: current.saveErrorCount + 1, + lastError: LiveTrackingError.fromSaveException(e), + ), + ); + } + } + } + + Future _pauseWithError(LiveTrackingError error) async { + final current = _session; + if (current == null) { + return; + } + _cancelSubscription(); + _setSession( + current.copyWith(status: LiveTrackingStatus.paused, lastError: error), + ); + await _notifications.showPaused(targetName: current.config.targetName); + await _writeTrackingStatus(active: false); + } + + /// Fire-and-forget teardown: [StreamSubscription.cancel] detaches the + /// listener synchronously, so awaiting its completion would only gate on the + /// plugin's native teardown — which we don't need to block session state on. + void _cancelSubscription() { + unawaited(_subscription?.cancel()); + _subscription = null; + } + + Future _writeTrackingStatus({ + required bool active, + bool includeTrackedBy = false, + }) async { + final config = _session?.config; + if (config == null) { + return; + } + try { + await _save(config, { + LiveTrackingKeyType.gpsActive: active, + if (includeTrackedBy) + LiveTrackingKeyType.gpsTrackedBy: config.trackedBy, + }); + } catch (e, s) { + _log.error('LiveLocationTrackingService: tracking status failed', e, s); + } + } + + /// Routes each configured key to attributes or time series under the label + /// the dashboard resolved for it. Values the dashboard did not ask for — and + /// values the device could not provide — are skipped. + Future _save( + LiveTrackingConfig config, + Map values, { + int? ts, + }) async { + final attributes = {}; + final telemetry = {}; + for (final key in config.keys) { + final value = values[key.key]; + if (value == null) { + continue; + } + switch (key.valueType) { + case LiveTrackingValueType.attribute: + attributes[key.label] = value; + case LiveTrackingValueType.timeseries: + telemetry[key.label] = value; + } + } + if (telemetry.isNotEmpty) { + await _remote.saveTelemetry( + config.target, + ts ?? DateTime.now().millisecondsSinceEpoch, + telemetry, + ); + } + if (attributes.isNotEmpty) { + await _remote.saveAttributes(config.target, attributes); + } + } + + void _setSession(LiveTrackingSession? session) { + _session = session; + _sessionController.add(session); + } +} diff --git a/lib/utils/services/live_location_tracking/live_tracking_display.dart b/lib/utils/services/live_location_tracking/live_tracking_display.dart new file mode 100644 index 00000000..af3aebef --- /dev/null +++ b/lib/utils/services/live_location_tracking/live_tracking_display.dart @@ -0,0 +1,11 @@ +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; + +/// Human-friendly label for a tracking target: the resolved entity name when +/// available, otherwise ` · `. +String displayTargetName(String? resolved, LiveTrackingTarget target) { + if (resolved != null && resolved.trim().isNotEmpty) { + return resolved; + } + final shortId = target.id.length > 8 ? target.id.substring(0, 8) : target.id; + return '${target.entityType} · $shortId'; +} diff --git a/lib/utils/services/live_location_tracking/live_tracking_notifications.dart b/lib/utils/services/live_location_tracking/live_tracking_notifications.dart new file mode 100644 index 00000000..cceecdce --- /dev/null +++ b/lib/utils/services/live_location_tracking/live_tracking_notifications.dart @@ -0,0 +1,83 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_local_notifications/flutter_local_notifications.dart'; +import 'package:thingsboard_app/config/routes/v2/routes_config/routes/location_tracking_routes.dart'; +import 'package:thingsboard_app/config/themes/app_colors.dart'; +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_notifications.dart'; + +/// Android-only ongoing notification shown while a live tracking session is +/// paused. While tracking is active the geolocator foreground service owns its +/// own notification; pausing cancels the GPS stream (and with it that +/// notification), so this one fills the gap. No-op on iOS, which has no +/// ongoing-notification concept. +/// +/// Notification strings are OS-level and English-only for v1, matching the +/// foreground-service strings in `BackgroundTrackingConfig`. +class LiveTrackingNotifications implements ILiveTrackingNotifications { + LiveTrackingNotifications({ + required TbLogger logger, + FlutterLocalNotificationsPlugin? plugin, + }) : _log = logger, + _plugin = plugin ?? FlutterLocalNotificationsPlugin(); + + final TbLogger _log; + final FlutterLocalNotificationsPlugin _plugin; + + static const _notificationId = 51001; + + /// Payload shape consumed by NotificationService's tap handler; deep-links + /// to the live tracking screen. + static final _payload = json.encode({ + 'enabled': true, + 'linkType': 'LINK', + 'link': LocationTrackingRoutes.liveTracking, + }); + + @override + Future showPaused({String? targetName}) async { + if (defaultTargetPlatform != TargetPlatform.android) { + return; + } + try { + await _plugin.show( + _notificationId, + 'ThingsBoard', + targetName == null + ? 'Live location tracking is paused' + : 'Live tracking of $targetName is paused', + NotificationDetails( + android: AndroidNotificationDetails( + 'live_tracking', + 'Live location tracking', + channelDescription: + 'Shows the state of the live location tracking session', + icon: '@drawable/ic_launcher_foreground', + color: AppColors.appPrimaryColor, + importance: Importance.low, + priority: Priority.low, + ongoing: true, + autoCancel: false, + showWhen: false, + ), + ), + payload: _payload, + ); + } catch (e, s) { + _log.error('LiveTrackingNotifications: show failed', e, s); + } + } + + @override + Future clear() async { + if (defaultTargetPlatform != TargetPlatform.android) { + return; + } + try { + await _plugin.cancel(_notificationId); + } catch (e, s) { + _log.error('LiveTrackingNotifications: cancel failed', e, s); + } + } +} diff --git a/lib/utils/services/live_location_tracking/live_tracking_remote.dart b/lib/utils/services/live_location_tracking/live_tracking_remote.dart new file mode 100644 index 00000000..ef8c42a6 --- /dev/null +++ b/lib/utils/services/live_location_tracking/live_tracking_remote.dart @@ -0,0 +1,44 @@ +import 'dart:convert'; + +import 'package:thingsboard_app/constants/app_constants.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_remote.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart'; + +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}), + extra: ThingsboardAppConstants.backgroundRequest, + ); + } + + @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), + extra: ThingsboardAppConstants.backgroundRequest, + ); + } +} diff --git a/lib/utils/services/live_location_tracking/live_tracking_store.dart b/lib/utils/services/live_location_tracking/live_tracking_store.dart new file mode 100644 index 00000000..bbf297d7 --- /dev/null +++ b/lib/utils/services/live_location_tracking/live_tracking_store.dart @@ -0,0 +1,53 @@ +import 'dart:convert'; + +import 'package:thingsboard_app/constants/database_keys.dart'; +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/thingsboard_client.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; + +class LiveTrackingStore implements ILiveTrackingStore { + LiveTrackingStore({required TbStorage storage, required TbLogger logger}) + : _storage = storage, + _log = logger; + + final TbStorage _storage; + final TbLogger _log; + + @override + Future read() async { + try { + final raw = await _storage.getItem(DatabaseKeys.liveTrackingLastRecord); + if (raw is! String) { + return null; + } + return LastTrackingRecord.fromJson( + Map.from(jsonDecode(raw) as Map), + ); + } catch (e, s) { + _log.error('LiveTrackingStore.read failed', e, s); + return null; + } + } + + @override + Future write(LastTrackingRecord record) async { + try { + await _storage.setItem( + DatabaseKeys.liveTrackingLastRecord, + jsonEncode(record.toJson()), + ); + } catch (e, s) { + _log.error('LiveTrackingStore.write failed', e, s); + } + } + + @override + Future clear() async { + try { + await _storage.deleteItem(DatabaseKeys.liveTrackingLastRecord); + } catch (e, s) { + _log.error('LiveTrackingStore.clear failed', e, s); + } + } +} diff --git a/lib/utils/services/live_location_tracking/model/last_tracking_record.dart b/lib/utils/services/live_location_tracking/model/last_tracking_record.dart new file mode 100644 index 00000000..bcb03c8a --- /dev/null +++ b/lib/utils/services/live_location_tracking/model/last_tracking_record.dart @@ -0,0 +1,102 @@ +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; + +enum TrackingEndReason { manual, maxDuration, interrupted } + +TrackingEndReason _endReasonFromString(String? value) => + TrackingEndReason.values.firstWhere( + (e) => e.name == value, + orElse: () => TrackingEndReason.interrupted, + ); + +/// Snapshot of the most recent tracking session, persisted so the idle page +/// can show it and relaunch it ("Start again"). Written at session start and +/// updated at session end. +class LastTrackingRecord { + const LastTrackingRecord({ + required this.configJson, + required this.startedAt, + required this.endReason, + this.targetName, + this.endedAt, + this.fixCount = 0, + this.savedCount = 0, + this.saveErrorCount = 0, + this.lastLat, + this.lastLng, + this.lastError, + }); + + factory LastTrackingRecord.fromJson(Map json) => + LastTrackingRecord( + configJson: Map.from(json['configJson'] as Map), + targetName: json['targetName'] as String?, + startedAt: DateTime.fromMillisecondsSinceEpoch( + (json['startedAt'] as num).toInt(), + ), + endedAt: + json['endedAt'] == null + ? null + : DateTime.fromMillisecondsSinceEpoch( + (json['endedAt'] as num).toInt(), + ), + fixCount: (json['fixCount'] as num?)?.toInt() ?? 0, + savedCount: (json['savedCount'] as num?)?.toInt() ?? 0, + saveErrorCount: (json['saveErrorCount'] as num?)?.toInt() ?? 0, + lastLat: (json['lastLat'] as num?)?.toDouble(), + lastLng: (json['lastLng'] as num?)?.toDouble(), + lastError: json['lastError'] as String?, + endReason: _endReasonFromString(json['endReason'] as String?), + ); + + final Map configJson; + final String? targetName; + final DateTime startedAt; + final DateTime? endedAt; + final int fixCount; + final int savedCount; + final int saveErrorCount; + final double? lastLat; + final double? lastLng; + final String? lastError; + final TrackingEndReason endReason; + + LiveTrackingConfig get config => LiveTrackingConfig.fromJson(configJson); + + Map toJson() => { + 'configJson': configJson, + 'targetName': targetName, + 'startedAt': startedAt.millisecondsSinceEpoch, + 'endedAt': endedAt?.millisecondsSinceEpoch, + 'fixCount': fixCount, + 'savedCount': savedCount, + 'saveErrorCount': saveErrorCount, + 'lastLat': lastLat, + 'lastLng': lastLng, + 'lastError': lastError, + 'endReason': endReason.name, + }; + + LastTrackingRecord copyWith({ + String? targetName, + DateTime? endedAt, + int? fixCount, + int? savedCount, + int? saveErrorCount, + double? lastLat, + double? lastLng, + String? lastError, + TrackingEndReason? endReason, + }) => LastTrackingRecord( + configJson: configJson, + targetName: targetName ?? this.targetName, + startedAt: startedAt, + endedAt: endedAt ?? this.endedAt, + fixCount: fixCount ?? this.fixCount, + savedCount: savedCount ?? this.savedCount, + saveErrorCount: saveErrorCount ?? this.saveErrorCount, + lastLat: lastLat ?? this.lastLat, + lastLng: lastLng ?? this.lastLng, + lastError: lastError ?? this.lastError, + endReason: endReason ?? this.endReason, + ); +} diff --git a/lib/utils/services/live_location_tracking/model/live_tracking_config.dart b/lib/utils/services/live_location_tracking/model/live_tracking_config.dart new file mode 100644 index 00000000..650c4c2a --- /dev/null +++ b/lib/utils/services/live_location_tracking/model/live_tracking_config.dart @@ -0,0 +1,206 @@ +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; + + Map toJson() => {'entityType': entityType, 'id': id}; +} + +/// Location values the dashboard can ask the app to save. Mirrors the +/// `LocationKey` enum on the web side. +enum LiveTrackingKeyType { + latitude('LATITUDE'), + longitude('LONGITUDE'), + accuracy('ACCURACY'), + altitude('ALTITUDE'), + speed('SPEED'), + heading('HEADING'), + gpsActive('GPS_ACTIVE'), + gpsTrackedBy('GPS_TRACKED_BY'); + + const LiveTrackingKeyType(this.wireValue); + + final String wireValue; + + static LiveTrackingKeyType? fromWireValue(String? value) { + for (final type in LiveTrackingKeyType.values) { + if (type.wireValue == value) { + return type; + } + } + return null; + } +} + +enum LiveTrackingValueType { + attribute('ATTRIBUTE'), + timeseries('TIMESERIES'); + + const LiveTrackingValueType(this.wireValue); + + final String wireValue; + + static LiveTrackingValueType fromWireValue(String? value) => + value == LiveTrackingValueType.timeseries.wireValue + ? LiveTrackingValueType.timeseries + : LiveTrackingValueType.attribute; +} + +/// One configured value: which location field to save, under which entity key, +/// and whether it lands in server attributes or time series. +class LiveTrackingKey { + const LiveTrackingKey({ + required this.key, + required this.label, + required this.valueType, + }); + + factory LiveTrackingKey.fromJson(Map json) { + final key = LiveTrackingKeyType.fromWireValue(json['key'] as String?); + final label = json['label']; + if (key == null || label is! String || label.isEmpty) { + throw const FormatException( + 'Live tracking key must contain a known key and a non-empty label', + ); + } + return LiveTrackingKey( + key: key, + label: label, + valueType: LiveTrackingValueType.fromWireValue( + json['valueType'] as String?, + ), + ); + } + + final LiveTrackingKeyType key; + final String label; + final LiveTrackingValueType valueType; + + Map toJson() => { + 'key': key.wireValue, + 'label': label, + 'valueType': valueType.wireValue, + }; +} + +/// The dashboard a tracking session was started from, for display and +/// navigation back to it. Both fields are optional on the wire. +class LiveTrackingDashboard { + const LiveTrackingDashboard({this.id, this.title}); + + factory LiveTrackingDashboard.fromJson(Map json) => + LiveTrackingDashboard( + id: json['id'] as String?, + title: json['title'] as String?, + ); + + final String? id; + final String? title; + + bool get isEmpty => id == null && title == null; + + Map toJson() => {'id': id, 'title': title}; +} + +/// Fully-resolved live tracking session config received from the dashboard +/// (the web side resolves aliases/attributes to a concrete target entity and +/// key labels to their effective names before sending). +class LiveTrackingConfig { + const LiveTrackingConfig({ + required this.target, + required this.keys, + this.targetName, + this.dashboard, + this.accuracy = LocationAccuracyLevel.balanced, + this.distanceFilterMeters, + this.intervalSeconds, + this.maxDurationSeconds, + this.trackedBy, + }); + + factory LiveTrackingConfig.fromJson(Map json) { + final targetJson = json['target']; + if (targetJson is! Map) { + throw const FormatException('Live tracking config is missing target'); + } + final keysJson = json['keys']; + if (keysJson is! List || keysJson.isEmpty) { + throw const FormatException('Live tracking config is missing keys'); + } + final dashboardJson = json['dashboard']; + final dashboard = + dashboardJson is Map + ? LiveTrackingDashboard.fromJson( + Map.from(dashboardJson), + ) + : null; + return LiveTrackingConfig( + target: LiveTrackingTarget.fromJson( + Map.from(targetJson), + ), + keys: keysJson + .map( + (key) => + LiveTrackingKey.fromJson(Map.from(key as Map)), + ) + .toList(growable: false), + targetName: json['targetName'] as String?, + dashboard: dashboard == null || dashboard.isEmpty ? null : dashboard, + accuracy: _accuracyFromString(json['accuracy'] as String?), + distanceFilterMeters: (json['distanceFilterMeters'] as num?)?.toInt(), + intervalSeconds: (json['intervalSeconds'] as num?)?.toInt(), + maxDurationSeconds: (json['maxDurationSeconds'] as num?)?.toInt(), + trackedBy: json['trackedBy'] as String?, + ); + } + + final LiveTrackingTarget target; + final List keys; + final String? targetName; + final LiveTrackingDashboard? dashboard; + final LocationAccuracyLevel accuracy; + final int? distanceFilterMeters; + final int? intervalSeconds; + final int? maxDurationSeconds; + final String? trackedBy; + + static LocationAccuracyLevel _accuracyFromString(String? value) => + switch (value) { + 'HIGH' => LocationAccuracyLevel.high, + 'LOW' => LocationAccuracyLevel.low, + _ => LocationAccuracyLevel.balanced, + }; + + Map toJson() => { + 'target': target.toJson(), + 'keys': keys.map((key) => key.toJson()).toList(growable: false), + 'targetName': targetName, + 'dashboard': dashboard?.toJson(), + 'accuracy': _accuracyToString(accuracy), + 'distanceFilterMeters': distanceFilterMeters, + 'intervalSeconds': intervalSeconds, + 'maxDurationSeconds': maxDurationSeconds, + 'trackedBy': trackedBy, + }; + + static String _accuracyToString(LocationAccuracyLevel level) => + switch (level) { + LocationAccuracyLevel.high => 'HIGH', + LocationAccuracyLevel.low => 'LOW', + LocationAccuracyLevel.balanced => 'BALANCED', + }; +} diff --git a/lib/utils/services/live_location_tracking/model/live_tracking_error.dart b/lib/utils/services/live_location_tracking/model/live_tracking_error.dart new file mode 100644 index 00000000..e8c1dff8 --- /dev/null +++ b/lib/utils/services/live_location_tracking/model/live_tracking_error.dart @@ -0,0 +1,39 @@ +import 'package:thingsboard_app/thingsboard_client.dart'; + +/// Why the session is degraded, as a cause the UI layer can localize. +/// Raw exception text (stack traces, URLs, Angular/Dio wording) must never +/// reach the session screen — details go to the logger only. +enum LiveTrackingError { + targetNotFound, + noConnection, + unauthorized, + saveFailed, + locationServicesDisabled, + locationPermissionDenied, + locationPermissionDeniedForever, + locationError; + + /// Classifies a failed save. A deleted target arrives with an empty server + /// message (`[404: ]`), so causes are derived from status/error code — + /// server text is never passed through. + static LiveTrackingError fromSaveException(Object e) { + final error = toThingsboardError(e); + if (error.errorCode == ThingsBoardErrorCode.general && + error.message == 'Unable to connect') { + return noConnection; + } + // The interceptor reports a missing token as a bare 'Unauthorized!' + // message with no status or error code. + if (error.status == 401 || + error.message == 'Unauthorized!' || + error.errorCode == ThingsBoardErrorCode.authentication || + error.errorCode == ThingsBoardErrorCode.jwtTokenExpired) { + return unauthorized; + } + if (error.status == 404 || + error.errorCode == ThingsBoardErrorCode.itemNotFound) { + return targetNotFound; + } + return saveFailed; + } +} diff --git a/lib/utils/services/live_location_tracking/model/live_tracking_session.dart b/lib/utils/services/live_location_tracking/model/live_tracking_session.dart new file mode 100644 index 00000000..e2382bee --- /dev/null +++ b/lib/utils/services/live_location_tracking/model/live_tracking_session.dart @@ -0,0 +1,22 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_error.dart'; +import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; + +part 'live_tracking_session.freezed.dart'; + +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, + LiveTrackingError? lastError, + }) = _LiveTrackingSession; +} diff --git a/lib/utils/services/live_location_tracking/model/live_tracking_session.freezed.dart b/lib/utils/services/live_location_tracking/model/live_tracking_session.freezed.dart new file mode 100644 index 00000000..e3785607 --- /dev/null +++ b/lib/utils/services/live_location_tracking/model/live_tracking_session.freezed.dart @@ -0,0 +1,316 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'live_tracking_session.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$LiveTrackingSession { + + LiveTrackingConfig get config; LiveTrackingStatus get status; DateTime get startedAt; int get fixCount; int get savedCount; int get saveErrorCount; GeoPosition? get lastFix; LiveTrackingError? get lastError; +/// Create a copy of LiveTrackingSession +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$LiveTrackingSessionCopyWith get copyWith => _$LiveTrackingSessionCopyWithImpl(this as LiveTrackingSession, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is LiveTrackingSession&&(identical(other.config, config) || other.config == config)&&(identical(other.status, status) || other.status == status)&&(identical(other.startedAt, startedAt) || other.startedAt == startedAt)&&(identical(other.fixCount, fixCount) || other.fixCount == fixCount)&&(identical(other.savedCount, savedCount) || other.savedCount == savedCount)&&(identical(other.saveErrorCount, saveErrorCount) || other.saveErrorCount == saveErrorCount)&&(identical(other.lastFix, lastFix) || other.lastFix == lastFix)&&(identical(other.lastError, lastError) || other.lastError == lastError)); +} + + +@override +int get hashCode => Object.hash(runtimeType,config,status,startedAt,fixCount,savedCount,saveErrorCount,lastFix,lastError); + +@override +String toString() { + return 'LiveTrackingSession(config: $config, status: $status, startedAt: $startedAt, fixCount: $fixCount, savedCount: $savedCount, saveErrorCount: $saveErrorCount, lastFix: $lastFix, lastError: $lastError)'; +} + + +} + +/// @nodoc +abstract mixin class $LiveTrackingSessionCopyWith<$Res> { + factory $LiveTrackingSessionCopyWith(LiveTrackingSession value, $Res Function(LiveTrackingSession) _then) = _$LiveTrackingSessionCopyWithImpl; +@useResult +$Res call({ + LiveTrackingConfig config, LiveTrackingStatus status, DateTime startedAt, int fixCount, int savedCount, int saveErrorCount, GeoPosition? lastFix, LiveTrackingError? lastError +}); + + +$GeoPositionCopyWith<$Res>? get lastFix; + +} +/// @nodoc +class _$LiveTrackingSessionCopyWithImpl<$Res> + implements $LiveTrackingSessionCopyWith<$Res> { + _$LiveTrackingSessionCopyWithImpl(this._self, this._then); + + final LiveTrackingSession _self; + final $Res Function(LiveTrackingSession) _then; + +/// Create a copy of LiveTrackingSession +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? config = null,Object? status = null,Object? startedAt = null,Object? fixCount = null,Object? savedCount = null,Object? saveErrorCount = null,Object? lastFix = freezed,Object? lastError = freezed,}) { + return _then(_self.copyWith( +config: null == config ? _self.config : config // ignore: cast_nullable_to_non_nullable +as LiveTrackingConfig,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as LiveTrackingStatus,startedAt: null == startedAt ? _self.startedAt : startedAt // ignore: cast_nullable_to_non_nullable +as DateTime,fixCount: null == fixCount ? _self.fixCount : fixCount // ignore: cast_nullable_to_non_nullable +as int,savedCount: null == savedCount ? _self.savedCount : savedCount // ignore: cast_nullable_to_non_nullable +as int,saveErrorCount: null == saveErrorCount ? _self.saveErrorCount : saveErrorCount // ignore: cast_nullable_to_non_nullable +as int,lastFix: freezed == lastFix ? _self.lastFix : lastFix // ignore: cast_nullable_to_non_nullable +as GeoPosition?,lastError: freezed == lastError ? _self.lastError : lastError // ignore: cast_nullable_to_non_nullable +as LiveTrackingError?, + )); +} +/// Create a copy of LiveTrackingSession +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$GeoPositionCopyWith<$Res>? get lastFix { + if (_self.lastFix == null) { + return null; + } + + return $GeoPositionCopyWith<$Res>(_self.lastFix!, (value) { + return _then(_self.copyWith(lastFix: value)); + }); +} +} + + +/// Adds pattern-matching-related methods to [LiveTrackingSession]. +extension LiveTrackingSessionPatterns on LiveTrackingSession { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _LiveTrackingSession value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _LiveTrackingSession() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _LiveTrackingSession value) $default,){ +final _that = this; +switch (_that) { +case _LiveTrackingSession(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _LiveTrackingSession value)? $default,){ +final _that = this; +switch (_that) { +case _LiveTrackingSession() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( LiveTrackingConfig config, LiveTrackingStatus status, DateTime startedAt, int fixCount, int savedCount, int saveErrorCount, GeoPosition? lastFix, LiveTrackingError? lastError)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _LiveTrackingSession() when $default != null: +return $default(_that.config,_that.status,_that.startedAt,_that.fixCount,_that.savedCount,_that.saveErrorCount,_that.lastFix,_that.lastError);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( LiveTrackingConfig config, LiveTrackingStatus status, DateTime startedAt, int fixCount, int savedCount, int saveErrorCount, GeoPosition? lastFix, LiveTrackingError? lastError) $default,) {final _that = this; +switch (_that) { +case _LiveTrackingSession(): +return $default(_that.config,_that.status,_that.startedAt,_that.fixCount,_that.savedCount,_that.saveErrorCount,_that.lastFix,_that.lastError);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( LiveTrackingConfig config, LiveTrackingStatus status, DateTime startedAt, int fixCount, int savedCount, int saveErrorCount, GeoPosition? lastFix, LiveTrackingError? lastError)? $default,) {final _that = this; +switch (_that) { +case _LiveTrackingSession() when $default != null: +return $default(_that.config,_that.status,_that.startedAt,_that.fixCount,_that.savedCount,_that.saveErrorCount,_that.lastFix,_that.lastError);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _LiveTrackingSession implements LiveTrackingSession { + const _LiveTrackingSession({required this.config, required this.status, required this.startedAt, this.fixCount = 0, this.savedCount = 0, this.saveErrorCount = 0, this.lastFix, this.lastError}); + + +@override final LiveTrackingConfig config; +@override final LiveTrackingStatus status; +@override final DateTime startedAt; +@override@JsonKey() final int fixCount; +@override@JsonKey() final int savedCount; +@override@JsonKey() final int saveErrorCount; +@override final GeoPosition? lastFix; +@override final LiveTrackingError? lastError; + +/// Create a copy of LiveTrackingSession +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$LiveTrackingSessionCopyWith<_LiveTrackingSession> get copyWith => __$LiveTrackingSessionCopyWithImpl<_LiveTrackingSession>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LiveTrackingSession&&(identical(other.config, config) || other.config == config)&&(identical(other.status, status) || other.status == status)&&(identical(other.startedAt, startedAt) || other.startedAt == startedAt)&&(identical(other.fixCount, fixCount) || other.fixCount == fixCount)&&(identical(other.savedCount, savedCount) || other.savedCount == savedCount)&&(identical(other.saveErrorCount, saveErrorCount) || other.saveErrorCount == saveErrorCount)&&(identical(other.lastFix, lastFix) || other.lastFix == lastFix)&&(identical(other.lastError, lastError) || other.lastError == lastError)); +} + + +@override +int get hashCode => Object.hash(runtimeType,config,status,startedAt,fixCount,savedCount,saveErrorCount,lastFix,lastError); + +@override +String toString() { + return 'LiveTrackingSession(config: $config, status: $status, startedAt: $startedAt, fixCount: $fixCount, savedCount: $savedCount, saveErrorCount: $saveErrorCount, lastFix: $lastFix, lastError: $lastError)'; +} + + +} + +/// @nodoc +abstract mixin class _$LiveTrackingSessionCopyWith<$Res> implements $LiveTrackingSessionCopyWith<$Res> { + factory _$LiveTrackingSessionCopyWith(_LiveTrackingSession value, $Res Function(_LiveTrackingSession) _then) = __$LiveTrackingSessionCopyWithImpl; +@override @useResult +$Res call({ + LiveTrackingConfig config, LiveTrackingStatus status, DateTime startedAt, int fixCount, int savedCount, int saveErrorCount, GeoPosition? lastFix, LiveTrackingError? lastError +}); + + +@override $GeoPositionCopyWith<$Res>? get lastFix; + +} +/// @nodoc +class __$LiveTrackingSessionCopyWithImpl<$Res> + implements _$LiveTrackingSessionCopyWith<$Res> { + __$LiveTrackingSessionCopyWithImpl(this._self, this._then); + + final _LiveTrackingSession _self; + final $Res Function(_LiveTrackingSession) _then; + +/// Create a copy of LiveTrackingSession +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? config = null,Object? status = null,Object? startedAt = null,Object? fixCount = null,Object? savedCount = null,Object? saveErrorCount = null,Object? lastFix = freezed,Object? lastError = freezed,}) { + return _then(_LiveTrackingSession( +config: null == config ? _self.config : config // ignore: cast_nullable_to_non_nullable +as LiveTrackingConfig,status: null == status ? _self.status : status // ignore: cast_nullable_to_non_nullable +as LiveTrackingStatus,startedAt: null == startedAt ? _self.startedAt : startedAt // ignore: cast_nullable_to_non_nullable +as DateTime,fixCount: null == fixCount ? _self.fixCount : fixCount // ignore: cast_nullable_to_non_nullable +as int,savedCount: null == savedCount ? _self.savedCount : savedCount // ignore: cast_nullable_to_non_nullable +as int,saveErrorCount: null == saveErrorCount ? _self.saveErrorCount : saveErrorCount // ignore: cast_nullable_to_non_nullable +as int,lastFix: freezed == lastFix ? _self.lastFix : lastFix // ignore: cast_nullable_to_non_nullable +as GeoPosition?,lastError: freezed == lastError ? _self.lastError : lastError // ignore: cast_nullable_to_non_nullable +as LiveTrackingError?, + )); +} + +/// Create a copy of LiveTrackingSession +/// with the given fields replaced by the non-null parameter values. +@override +@pragma('vm:prefer-inline') +$GeoPositionCopyWith<$Res>? get lastFix { + if (_self.lastFix == null) { + return null; + } + + return $GeoPositionCopyWith<$Res>(_self.lastFix!, (value) { + return _then(_self.copyWith(lastFix: value)); + }); +} +} + +// dart format on diff --git a/lib/utils/services/location/i_location_service.dart b/lib/utils/services/location/i_location_service.dart new file mode 100644 index 00000000..42d92c81 --- /dev/null +++ b/lib/utils/services/location/i_location_service.dart @@ -0,0 +1,27 @@ +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 +/// plugin directly. +abstract interface class ILocationService { + /// Resolves a single current position, performing the full permission and + /// service-enabled checks internally. + Future getCurrentPosition(); + + /// 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(); + + /// 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..eb85f650 --- /dev/null +++ b/lib/utils/services/location/location_service.dart @@ -0,0 +1,148 @@ +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}) + : _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 _fixFromPlatformError(e); + } + } + + @override + Stream positionStream({ + LocationStreamSettings settings = const LocationStreamSettings(), + }) async* { + final unavailable = await _ensureAvailable(); + if (unavailable != null) { + yield unavailable; + return; + } + + final raw = _geolocator.getPositionStream( + locationSettings: _toLocationSettings(settings), + ); + + 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(_fixFromPlatformError(e)); + }, + ), + ); + } + + /// Geolocator reports a mid-stream loss of location availability as a + /// stream *error*, not as a status; mapping it back to the sealed cases + /// keeps the "services disabled" / "permission denied" handling identical + /// whether it happens at start or mid-session. + LocationFix _fixFromPlatformError(Object e) => switch (e) { + LocationServiceDisabledException() => const LocationServicesDisabled(), + PermissionDeniedException() => const LocationPermissionDenied(), + _ => LocationFixError(e.toString()), + }; + + @override + Future openLocationSettings() => _geolocator.openLocationSettings(); + + @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; + } + + 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, + accuracy: p.accuracy, + timestamp: p.timestamp, + altitude: p.altitude, + speed: p.speed, + heading: p.heading, + ); +} diff --git a/lib/utils/services/location/model/geo_position.dart b/lib/utils/services/location/model/geo_position.dart new file mode 100644 index 00000000..afdfaafa --- /dev/null +++ b/lib/utils/services/location/model/geo_position.dart @@ -0,0 +1,18 @@ +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, + double? altitude, + double? speed, + double? heading, + }) = _GeoPosition; +} diff --git a/lib/utils/services/location/model/geo_position.freezed.dart b/lib/utils/services/location/model/geo_position.freezed.dart new file mode 100644 index 00000000..109c1e12 --- /dev/null +++ b/lib/utils/services/location/model/geo_position.freezed.dart @@ -0,0 +1,289 @@ +// 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; double? get altitude; double? get speed; double? get heading; +/// Create a copy of GeoPosition +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@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)&&(identical(other.altitude, altitude) || other.altitude == altitude)&&(identical(other.speed, speed) || other.speed == speed)&&(identical(other.heading, heading) || other.heading == heading)); +} + + +@override +int get hashCode => Object.hash(runtimeType,latitude,longitude,accuracy,timestamp,altitude,speed,heading); + +@override +String toString() { + return 'GeoPosition(latitude: $latitude, longitude: $longitude, accuracy: $accuracy, timestamp: $timestamp, altitude: $altitude, speed: $speed, heading: $heading)'; +} + + +} + +/// @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, double? altitude, double? speed, double? heading +}); + + + + +} +/// @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,Object? altitude = freezed,Object? speed = freezed,Object? heading = freezed,}) { + return _then(_self.copyWith( +latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable +as double,longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable +as double,accuracy: null == accuracy ? _self.accuracy : accuracy // ignore: cast_nullable_to_non_nullable +as double,timestamp: freezed == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime?,altitude: freezed == altitude ? _self.altitude : altitude // ignore: cast_nullable_to_non_nullable +as double?,speed: freezed == speed ? _self.speed : speed // ignore: cast_nullable_to_non_nullable +as double?,heading: freezed == heading ? _self.heading : heading // ignore: cast_nullable_to_non_nullable +as double?, + )); +} + +} + + +/// 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, double? altitude, double? speed, double? heading)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _GeoPosition() when $default != null: +return $default(_that.latitude,_that.longitude,_that.accuracy,_that.timestamp,_that.altitude,_that.speed,_that.heading);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, double? altitude, double? speed, double? heading) $default,) {final _that = this; +switch (_that) { +case _GeoPosition(): +return $default(_that.latitude,_that.longitude,_that.accuracy,_that.timestamp,_that.altitude,_that.speed,_that.heading);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, double? altitude, double? speed, double? heading)? $default,) {final _that = this; +switch (_that) { +case _GeoPosition() when $default != null: +return $default(_that.latitude,_that.longitude,_that.accuracy,_that.timestamp,_that.altitude,_that.speed,_that.heading);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _GeoPosition implements GeoPosition { + const _GeoPosition({required this.latitude, required this.longitude, required this.accuracy, this.timestamp, this.altitude, this.speed, this.heading}); + + +@override final double latitude; +@override final double longitude; +@override final double accuracy; +@override final DateTime? timestamp; +@override final double? altitude; +@override final double? speed; +@override final double? heading; + +/// Create a copy of GeoPosition +/// with the given fields replaced by the non-null parameter values. +@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)&&(identical(other.altitude, altitude) || other.altitude == altitude)&&(identical(other.speed, speed) || other.speed == speed)&&(identical(other.heading, heading) || other.heading == heading)); +} + + +@override +int get hashCode => Object.hash(runtimeType,latitude,longitude,accuracy,timestamp,altitude,speed,heading); + +@override +String toString() { + return 'GeoPosition(latitude: $latitude, longitude: $longitude, accuracy: $accuracy, timestamp: $timestamp, altitude: $altitude, speed: $speed, heading: $heading)'; +} + + +} + +/// @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, double? altitude, double? speed, double? heading +}); + + + + +} +/// @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,Object? altitude = freezed,Object? speed = freezed,Object? heading = freezed,}) { + return _then(_GeoPosition( +latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable +as double,longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable +as double,accuracy: null == accuracy ? _self.accuracy : accuracy // ignore: cast_nullable_to_non_nullable +as double,timestamp: freezed == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime?,altitude: freezed == altitude ? _self.altitude : altitude // ignore: cast_nullable_to_non_nullable +as double?,speed: freezed == speed ? _self.speed : speed // ignore: cast_nullable_to_non_nullable +as double?,heading: freezed == heading ? _self.heading : heading // ignore: cast_nullable_to_non_nullable +as double?, + )); +} + + +} + +// 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; +} 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; +} 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..881a0945 --- /dev/null +++ b/lib/utils/services/mobile_actions/actions/location_action_result_mapper.dart @@ -0,0 +1,34 @@ +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 +/// the one-shot `GetLocationAction` so its success/failure result contract +/// stays in one place. +mixin LocationActionResultMapper { + WidgetMobileActionResult mapLocationFixToResult(LocationFix fix) { + return switch (fix) { + LocationSuccess(:final position) => + WidgetMobileActionResult.successResult( + MobileActionResult.location( + position.latitude, + position.longitude, + accuracy: position.accuracy, + ts: position.timestamp?.millisecondsSinceEpoch, + ), + ), + 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, + ), + }; + } +} diff --git a/lib/utils/services/mobile_actions/actions/start_live_location_action.dart b/lib/utils/services/mobile_actions/actions/start_live_location_action.dart new file mode 100644 index 00000000..838f7fa6 --- /dev/null +++ b/lib/utils/services/mobile_actions/actions/start_live_location_action.dart @@ -0,0 +1,40 @@ +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action_result.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_result.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_type.dart'; + +/// Starts a live tracking session from a fully-resolved dashboard config +/// (see the phase 1c wire protocol). Replaces any active session. +class StartLiveLocationAction extends MobileAction { + @override + Future execute( + List args, + InAppWebViewController controller, + ) async { + try { + if (args.length < 2 || args[1] is! Map) { + return WidgetMobileActionResult.errorResult( + 'Live tracking config is missing.', + ); + } + final config = LiveTrackingConfig.fromJson( + Map.from(args[1] as Map), + ); + await getIt().start(config); + return WidgetMobileActionResult.successResult( + MobileActionResult.launched(true), + ); + } on FormatException catch (e) { + return WidgetMobileActionResult.errorResult(e.message); + } catch (e) { + return handleError(e); + } + } + + @override + WidgetMobileActionType get type => WidgetMobileActionType.startLiveLocation; +} diff --git a/lib/utils/services/mobile_actions/actions/stop_live_location_action.dart b/lib/utils/services/mobile_actions/actions/stop_live_location_action.dart new file mode 100644 index 00000000..8760fdb0 --- /dev/null +++ b/lib/utils/services/mobile_actions/actions/stop_live_location_action.dart @@ -0,0 +1,44 @@ +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_display.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action_result.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_result.dart'; +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(); + final session = service.session; + if (session == null) { + return WidgetMobileActionResult.emptyResult(); + } + final config = session.config; + final resolvedName = + config.targetName ?? + (await getIt().read())?.targetName; + await service.stop(); + return WidgetMobileActionResult.successResult( + MobileActionResult.launched( + true, + trackingInfo: { + 'targetName': displayTargetName(resolvedName, config.target), + 'keys': config.keys.map((key) => key.label).toList(growable: false), + }, + ), + ); + } catch (e) { + return handleError(e); + } + } + + @override + WidgetMobileActionType get type => WidgetMobileActionType.stopLiveLocation; +} diff --git a/lib/utils/services/mobile_actions/mobile_action_result.dart b/lib/utils/services/mobile_actions/mobile_action_result.dart index 992b11c7..153d4d84 100644 --- a/lib/utils/services/mobile_actions/mobile_action_result.dart +++ b/lib/utils/services/mobile_actions/mobile_action_result.dart @@ -3,11 +3,15 @@ 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) { - return LaunchResult(launched); + factory MobileActionResult.launched( + bool launched, { + Map? trackingInfo, + }) { + return LaunchResult(launched, trackingInfo: trackingInfo); } factory MobileActionResult.image(String imageUrl) { @@ -18,8 +22,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/launch_result.dart b/lib/utils/services/mobile_actions/results/launch_result.dart index af3fb4c6..0a097aa8 100644 --- a/lib/utils/services/mobile_actions/results/launch_result.dart +++ b/lib/utils/services/mobile_actions/results/launch_result.dart @@ -1,14 +1,21 @@ import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action_result.dart'; class LaunchResult extends MobileActionResult { - - LaunchResult(this.launched); + LaunchResult(this.launched, {this.trackingInfo}); bool launched; + /// Live tracking session details ({targetName, keys}) attached to + /// start/stop live location acks so the dashboard can show what is being + /// saved and where. + Map? trackingInfo; + @override Map toJson() { final json = super.toJson(); json['launched'] = launched; + if (trackingInfo != null) { + json['trackingInfo'] = trackingInfo; + } return json; } } diff --git a/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/lib/utils/services/mobile_actions/widget_action_handler.dart b/lib/utils/services/mobile_actions/widget_action_handler.dart index efb881b2..d8453c25 100644 --- a/lib/utils/services/mobile_actions/widget_action_handler.dart +++ b/lib/utils/services/mobile_actions/widget_action_handler.dart @@ -1,4 +1,3 @@ - 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_location_action.dart'; @@ -6,14 +5,17 @@ import 'package:thingsboard_app/utils/services/mobile_actions/actions/make_phone import 'package:thingsboard_app/utils/services/mobile_actions/actions/scan_qr_action.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/show_map_location_action.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/show_map_with_directions_action.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/actions/start_live_location_action.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/actions/stop_live_location_action.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/take_photo_action.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/take_picture_from_gallery_action.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/take_screenshot_action.dart'; 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 +25,8 @@ class WidgetActionHandler { ScanQrAction(), MakePhoneCallAction(), GetLocationAction(), + StartLiveLocationAction(), + StopLiveLocationAction(), TakeScreenshotAction(), ]; Future> handleWidgetMobileAction( @@ -43,19 +47,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..d92ca8c8 100644 --- a/lib/utils/services/mobile_actions/widget_mobile_action_type.dart +++ b/lib/utils/services/mobile_actions/widget_mobile_action_type.dart @@ -6,6 +6,8 @@ enum WidgetMobileActionType { scanQrCode, makePhoneCall, getLocation, + startLiveLocation, + stopLiveLocation, takeScreenshot, deviceProvision, unknown; diff --git a/pubspec.lock b/pubspec.lock index aa431799..9780164d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -563,7 +563,7 @@ packages: source: hosted version: "4.1.1" fake_async: - dependency: transitive + dependency: "direct dev" description: name: fake_async sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" diff --git a/pubspec.yaml b/pubspec.yaml index 3c0bec4f..cf8f4132 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -101,6 +101,7 @@ dev_dependencies: sdk: flutter flutter_launcher_icons: ^0.14.4 mocktail: ^1.0.3 + fake_async: ^1.3.2 bloc_test: ^9.1.7 flutter_lints: ^2.0.3 build_runner: ^2.4.9 diff --git a/test/modules/location_tracking/live_tracking_bar_test.dart b/test/modules/location_tracking/live_tracking_bar_test.dart new file mode 100644 index 00000000..1790f611 --- /dev/null +++ b/test/modules/location_tracking/live_tracking_bar_test.dart @@ -0,0 +1,134 @@ +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'), + keys: [ + LiveTrackingKey( + key: LiveTrackingKeyType.latitude, + label: 'latitude', + valueType: LiveTrackingValueType.attribute, + ), + ], + ), + 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); + }); + + testWidgets( + 'collapsed bar spans full width and shows gps_fixed when tracking', + (tester) async { + tracking.session = LiveTrackingSession( + config: const LiveTrackingConfig( + target: LiveTrackingTarget(entityType: 'DEVICE', id: 'd-1'), + keys: [ + LiveTrackingKey( + key: LiveTrackingKeyType.latitude, + label: 'latitude', + valueType: LiveTrackingValueType.attribute, + ), + ], + ), + status: LiveTrackingStatus.tracking, + startedAt: DateTime.fromMillisecondsSinceEpoch(0), + ); + + await tester.pumpWidget(_wrap(const LiveTrackingBar())); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.expand_less)); + await tester.pump(); + + expect(find.byIcon(Icons.gps_fixed), findsOneWidget); + final collapsedMaterialFinder = + find + .ancestor( + of: find.byIcon(Icons.gps_fixed), + matching: find.byType(Material), + ) + .first; + final material = tester.widget(collapsedMaterialFinder); + final colors = + Theme.of(tester.element(collapsedMaterialFinder)).colorScheme; + expect(material.color, colors.primaryContainer); + final size = tester.getSize(collapsedMaterialFinder); + expect( + size.width, + tester.view.physicalSize.width / tester.view.devicePixelRatio, + ); + }, + ); +} diff --git a/test/modules/location_tracking/live_tracking_page_test.dart b/test/modules/location_tracking/live_tracking_page_test.dart new file mode 100644 index 00000000..85446e22 --- /dev/null +++ b/test/modules/location_tracking/live_tracking_page_test.dart @@ -0,0 +1,105 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:thingsboard_app/generated/l10n.dart'; +import 'package:thingsboard_app/modules/location_tracking/presentation/view/live_tracking_page.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; + +class _FakeStore implements ILiveTrackingStore { + _FakeStore(this.record); + final LastTrackingRecord? record; + @override + Future read() async => record; + @override + Future write(LastTrackingRecord r) async {} + @override + Future clear() async {} +} + +class _FakeResolver implements IEntityNameResolver { + @override + Future resolveName(String entityType, String id) async => null; +} + +class _FakeTrackingService implements ILiveLocationTrackingService { + final _controller = StreamController.broadcast(); + + @override + LiveTrackingSession? session; + + @override + Stream get sessionStream => _controller.stream; + + @override + Future start(LiveTrackingConfig config) async {} + + @override + Future stop() async {} + + @override + Future pause() async {} + + @override + Future resume() async {} +} + +Widget _wrap() => const ProviderScope( + child: MaterialApp( + localizationsDelegates: [S.delegate], + home: LiveTrackingPage(), + ), +); + +void main() { + tearDown(() => GetIt.I.reset()); + + testWidgets('idle with no record shows the empty message', (tester) async { + GetIt.I.registerLazySingleton(() => _FakeStore(null)); + GetIt.I.registerLazySingleton(() => _FakeResolver()); + GetIt.I.registerLazySingleton( + () => _FakeTrackingService(), + ); + + await tester.pumpWidget(_wrap()); + await tester.pumpAndSettle(); + + expect( + find.text('No active tracking and no recent session.'), + findsOneWidget, + ); + }); + + testWidgets('idle with a record shows Start again', (tester) async { + final record = LastTrackingRecord( + configJson: const { + 'target': {'entityType': 'DEVICE', 'id': 'd-1'}, + 'keys': [ + {'key': 'LATITUDE', 'label': 'latitude', 'valueType': 'ATTRIBUTE'}, + ], + }, + startedAt: DateTime.fromMillisecondsSinceEpoch(0), + endedAt: DateTime.fromMillisecondsSinceEpoch(60000), + endReason: TrackingEndReason.manual, + targetName: 'My Tracker', + ); + GetIt.I.registerLazySingleton(() => _FakeStore(record)); + GetIt.I.registerLazySingleton(() => _FakeResolver()); + GetIt.I.registerLazySingleton( + () => _FakeTrackingService(), + ); + + await tester.pumpWidget(_wrap()); + await tester.pumpAndSettle(); + + expect(find.text('Start again'), findsOneWidget); + expect(find.text('My Tracker'), findsOneWidget); + }); +} diff --git a/test/modules/location_tracking/navigation_helper_live_tracking_test.dart b/test/modules/location_tracking/navigation_helper_live_tracking_test.dart new file mode 100644 index 00000000..aba91f2c --- /dev/null +++ b/test/modules/location_tracking/navigation_helper_live_tracking_test.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/modules/main/providers/navigation_helper.dart'; +import 'package:thingsboard_app/utils/services/layouts/pages_layout.dart'; + +void main() { + const layout = PageLayout(id: Pages.live_location_tracking); + + test('parses LIVE_LOCATION_TRACKING from server string', () { + expect( + pagesFromString('LIVE_LOCATION_TRACKING'), + Pages.live_location_tracking, + ); + }); + + test('maps to route, label and icon', () { + expect(NavigationHelper.getPath(layout), '/liveTracking'); + expect(NavigationHelper.getLabel(layout), 'Live location tracking'); + expect(NavigationHelper.getIcon(layout), Icons.my_location); + }); +} diff --git a/test/utils/services/live_location_tracking/last_tracking_record_test.dart b/test/utils/services/live_location_tracking/last_tracking_record_test.dart new file mode 100644 index 00000000..a7907535 --- /dev/null +++ b/test/utils/services/live_location_tracking/last_tracking_record_test.dart @@ -0,0 +1,50 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; + +void main() { + final json = { + 'configJson': { + 'target': {'entityType': 'DEVICE', 'id': 'd-1'}, + 'keys': [ + {'key': 'LATITUDE', 'label': 'latitude', 'valueType': 'ATTRIBUTE'}, + ], + }, + 'targetName': 'My Tracker', + 'startedAt': 1720000000000, + 'endedAt': 1720000600000, + 'fixCount': 12, + 'savedCount': 11, + 'saveErrorCount': 1, + 'lastLat': 1.5, + 'lastLng': 2.5, + 'lastError': 'boom', + 'endReason': 'maxDuration', + }; + + test('fromJson/toJson round-trips', () { + final record = LastTrackingRecord.fromJson(json); + expect(record.targetName, 'My Tracker'); + expect(record.startedAt.millisecondsSinceEpoch, 1720000000000); + expect(record.endedAt?.millisecondsSinceEpoch, 1720000600000); + expect(record.fixCount, 12); + expect(record.savedCount, 11); + expect(record.saveErrorCount, 1); + expect(record.lastLat, 1.5); + expect(record.lastLng, 2.5); + expect(record.lastError, 'boom'); + expect(record.endReason, TrackingEndReason.maxDuration); + expect(record.config.target.id, 'd-1'); + expect( + LastTrackingRecord.fromJson(record.toJson()).toJson(), + record.toJson(), + ); + }); + + test('unknown endReason falls back to interrupted', () { + final record = LastTrackingRecord.fromJson({ + ...json, + 'endReason': 'nonsense', + }); + expect(record.endReason, TrackingEndReason.interrupted); + }); +} diff --git a/test/utils/services/live_location_tracking/live_location_tracking_service_test.dart b/test/utils/services/live_location_tracking/live_location_tracking_service_test.dart new file mode 100644 index 00000000..aec0920f --- /dev/null +++ b/test/utils/services/live_location_tracking/live_location_tracking_service_test.dart @@ -0,0 +1,517 @@ +import 'dart:async'; + +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_entity_name_resolver.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_notifications.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_remote.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_error.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; +import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; +import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; +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 FakeStore implements ILiveTrackingStore { + LastTrackingRecord? record; + int writeCount = 0; + + @override + Future read() async => record; + + @override + Future write(LastTrackingRecord r) async { + record = r; + writeCount++; + } + + @override + Future clear() async => record = null; +} + +class FakeNameResolver implements IEntityNameResolver { + String? name = 'My Tracker'; + + /// When set, [resolveName] returns this completer's future instead of + /// resolving immediately, letting tests control exactly when the + /// (network) name lookup completes relative to other service calls. + Completer? pendingCompleter; + + @override + Future resolveName(String entityType, String id) { + final pending = pendingCompleter; + if (pending != null) { + return pending.future; + } + return Future.value(name); + } +} + +class FakeNotifications implements ILiveTrackingNotifications { + final shownTargetNames = []; + int clearCount = 0; + bool pausedShown = false; + + @override + Future showPaused({String? targetName}) async { + shownTargetNames.add(targetName); + pausedShown = true; + } + + @override + Future clear() async { + clearCount++; + pausedShown = false; + } +} + +class FakeRemote implements ILiveTrackingRemote { + final telemetryCalls = <(LiveTrackingTarget, int, Map)>[]; + final attributeCalls = <(LiveTrackingTarget, Map)>[]; + 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)); + } +} + +LiveTrackingKey _key( + LiveTrackingKeyType key, + String label, + LiveTrackingValueType valueType, +) => LiveTrackingKey(key: key, label: label, valueType: valueType); + +void main() { + const target = LiveTrackingTarget(entityType: 'DEVICE', id: 'd-1'); + final positionKeys = [ + _key( + LiveTrackingKeyType.latitude, + 'latitude', + LiveTrackingValueType.attribute, + ), + _key( + LiveTrackingKeyType.longitude, + 'longitude', + LiveTrackingValueType.attribute, + ), + ]; + final statusKeys = [ + _key( + LiveTrackingKeyType.gpsActive, + 'gpsActive', + LiveTrackingValueType.attribute, + ), + _key( + LiveTrackingKeyType.gpsTrackedBy, + 'gpsTrackedBy', + LiveTrackingValueType.attribute, + ), + ]; + final fix = GeoPosition( + latitude: 1, + longitude: 2, + accuracy: 5, + timestamp: DateTime.fromMillisecondsSinceEpoch(1720000000000), + altitude: 100, + speed: 3, + heading: 90, + ); + + LiveTrackingConfig configOf({ + List? keys, + String? trackedBy, + int? maxDurationSeconds, + String? targetName, + }) => LiveTrackingConfig( + target: target, + keys: keys ?? positionKeys, + trackedBy: trackedBy, + maxDurationSeconds: maxDurationSeconds, + targetName: targetName, + ); + + late FakeLocationService location; + late FakeRemote remote; + late FakeStore store; + late FakeNameResolver nameResolver; + late FakeNotifications notifications; + late LiveLocationTrackingService service; + + setUp(() { + location = FakeLocationService(); + remote = FakeRemote(); + store = FakeStore(); + nameResolver = FakeNameResolver(); + notifications = FakeNotifications(); + service = LiveLocationTrackingService( + locationService: location, + remote: remote, + logger: TbLogger(), + store: store, + nameResolver: nameResolver, + notifications: notifications, + ); + }); + + test('start emits a tracking session and writes status keys', () async { + await service.start( + configOf(keys: [...positionKeys, ...statusKeys], 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 without status keys configured writes nothing', () async { + await service.start(configOf(trackedBy: 'me@tb.io')); + + expect(remote.attributeCalls, isEmpty); + }); + + test('fix routes each configured key to its label and value type', () async { + await service.start( + configOf( + keys: [ + _key( + LiveTrackingKeyType.latitude, + 'lat', + LiveTrackingValueType.attribute, + ), + _key( + LiveTrackingKeyType.longitude, + 'lng', + LiveTrackingValueType.attribute, + ), + _key( + LiveTrackingKeyType.accuracy, + 'gpsAccuracy', + LiveTrackingValueType.timeseries, + ), + _key( + LiveTrackingKeyType.speed, + 'gpsSpeed', + LiveTrackingValueType.timeseries, + ), + ], + ), + ); + remote.attributeCalls.clear(); + + location.controller!.add(LocationSuccess(fix)); + await pumpEventQueue(); + + final (savedTarget, ts, values) = remote.telemetryCalls.single; + expect(savedTarget.id, 'd-1'); + expect(ts, 1720000000000); + expect(values, {'gpsAccuracy': 5.0, 'gpsSpeed': 3.0}); + expect(remote.attributeCalls.single.$2, {'lat': 1.0, 'lng': 2.0}); + expect(service.session?.fixCount, 1); + expect(service.session?.savedCount, 1); + expect(service.session?.lastFix, fix); + }); + + test('values the dashboard did not ask for are not saved', () async { + await service.start( + configOf( + keys: [ + _key( + LiveTrackingKeyType.latitude, + 'latitude', + LiveTrackingValueType.timeseries, + ), + _key( + LiveTrackingKeyType.longitude, + 'longitude', + LiveTrackingValueType.timeseries, + ), + ], + ), + ); + + location.controller!.add(LocationSuccess(fix)); + await pumpEventQueue(); + + expect(remote.telemetryCalls.single.$3, { + 'latitude': 1.0, + 'longitude': 2.0, + }); + expect(remote.attributeCalls, isEmpty); + }); + + test('save failure increments saveErrorCount and keeps tracking', () async { + await service.start( + configOf( + keys: [ + _key( + LiveTrackingKeyType.latitude, + 'latitude', + LiveTrackingValueType.timeseries, + ), + ], + ), + ); + remote.throwOnTelemetry = Exception('boom'); + + location.controller!.add(LocationSuccess(fix)); + await pumpEventQueue(); + + expect(service.session?.status, LiveTrackingStatus.tracking); + expect(service.session?.saveErrorCount, 1); + expect(service.session?.savedCount, 0); + expect(service.session?.lastError, LiveTrackingError.saveFailed); + }); + + test('a successful fix clears the previous error', () async { + await service.start( + configOf( + keys: [ + _key( + LiveTrackingKeyType.latitude, + 'latitude', + LiveTrackingValueType.timeseries, + ), + ], + ), + ); + remote.throwOnTelemetry = Exception('boom'); + location.controller!.add(LocationSuccess(fix)); + await pumpEventQueue(); + expect(service.session?.lastError, LiveTrackingError.saveFailed); + + remote.throwOnTelemetry = null; + location.controller!.add(LocationSuccess(fix)); + await pumpEventQueue(); + + expect(service.session?.lastError, isNull); + expect(service.session?.savedCount, 1); + expect(service.session?.saveErrorCount, 1); + }); + + test( + 'services disabled mid-session pauses and writes gpsActive=false', + () async { + await service.start(configOf(keys: [...positionKeys, ...statusKeys])); + remote.attributeCalls.clear(); + + location.controller!.add(const LocationServicesDisabled()); + await pumpEventQueue(); + + expect(service.session?.status, LiveTrackingStatus.paused); + expect( + service.session?.lastError, + LiveTrackingError.locationServicesDisabled, + ); + expect(remote.attributeCalls.single.$2, {'gpsActive': false}); + expect(notifications.pausedShown, true); + }, + ); + + test( + 'pause cancels the stream and writes gpsActive=false; resume restores', + () async { + await service.start(configOf(keys: [...positionKeys, ...statusKeys])); + 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(configOf(keys: [...positionKeys, ...statusKeys])); + 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(configOf()); + + location.controller!.add(const LocationPermissionDenied()); + await pumpEventQueue(); + + expect(service.session?.status, LiveTrackingStatus.paused); + expect(service.session?.lastError, isNotNull); + expect(notifications.pausedShown, true); + }, + ); + + test('pause shows the paused notification with the config target name ' + 'and resume clears it', () async { + await service.start(configOf(targetName: 'Car 42')); + + await service.pause(); + expect(notifications.pausedShown, true); + expect(notifications.shownTargetNames.single, 'Car 42'); + + await service.resume(); + expect(notifications.pausedShown, false); + }); + + test('stop while paused clears the paused notification', () async { + await service.start(configOf()); + await service.pause(); + expect(notifications.pausedShown, true); + + await service.stop(); + expect(notifications.pausedShown, false); + }); + + test('maxDurationSeconds auto-stops the session', () { + fakeAsync((async) { + service.start(configOf(maxDurationSeconds: 300)); + async.flushMicrotasks(); + expect(service.session, isNotNull); + + async.elapse(const Duration(minutes: 5, seconds: 1)); + async.flushMicrotasks(); + expect(service.session, isNull); + }); + }); + + test('start writes an interrupted record with the resolved name', () async { + await service.start(configOf()); + // Name resolution now happens off the critical path (see finding #1); + // let its fire-and-forget patch land before asserting on it. + await pumpEventQueue(); + expect(store.record, isNotNull); + expect(store.record!.targetName, 'My Tracker'); + expect(store.record!.endReason, TrackingEndReason.interrupted); + expect(store.record!.endedAt, isNull); + }); + + test('a name resolution that completes after stop() does not resurrect the ' + 'stopped session, its subscription, or its persisted record', () async { + final resolverCompleter = Completer(); + nameResolver.pendingCompleter = resolverCompleter; + + unawaited(service.start(configOf())); + await pumpEventQueue(); + + // The interrupted record and GPS subscription must already exist + // before the name ever resolves: resolveName must not gate them. + expect(store.record, isNotNull); + expect(store.record!.endReason, TrackingEndReason.interrupted); + expect(store.record!.targetName, isNull); + expect(location.controller?.hasListener, true); + + await service.stop(); + await pumpEventQueue(); + + expect(service.session, isNull); + expect(store.record!.endReason, TrackingEndReason.manual); + expect(location.controller?.hasListener, false); + + resolverCompleter.complete('Late Name'); + await pumpEventQueue(); + + expect( + service.session, + isNull, + reason: 'a late name resolution must not resurrect the session', + ); + expect( + store.record!.endReason, + TrackingEndReason.manual, + reason: 'a late name resolution must not overwrite the ended record', + ); + expect(store.record!.targetName, isNot('Late Name')); + expect( + location.controller?.hasListener, + false, + reason: 'a late name resolution must not resurrect the subscription', + ); + }); + + test('stop updates the record with manual end reason and counts', () async { + await service.start(configOf()); + location.controller!.add(LocationSuccess(fix)); + await pumpEventQueue(); + + await service.stop(); + expect(store.record!.endReason, TrackingEndReason.manual); + expect(store.record!.endedAt, isNotNull); + expect(store.record!.fixCount, 1); + expect(store.record!.savedCount, 1); + expect(store.record!.lastLat, 1.0); + expect(store.record!.lastLng, 2.0); + }); + + test('max-duration end writes maxDuration reason', () { + fakeAsync((async) { + service.start(configOf(maxDurationSeconds: 300)); + async.flushMicrotasks(); + async.elapse(const Duration(minutes: 5, seconds: 1)); + async.flushMicrotasks(); + expect(store.record!.endReason, TrackingEndReason.maxDuration); + }); + }); +} diff --git a/test/utils/services/live_location_tracking/live_tracking_config_test.dart b/test/utils/services/live_location_tracking/live_tracking_config_test.dart new file mode 100644 index 00000000..a39e1705 --- /dev/null +++ b/test/utils/services/live_location_tracking/live_tracking_config_test.dart @@ -0,0 +1,187 @@ +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'}, + 'keys': [ + {'key': 'LATITUDE', 'label': 'lat', 'valueType': 'ATTRIBUTE'}, + {'key': 'LONGITUDE', 'label': 'lng', 'valueType': 'ATTRIBUTE'}, + {'key': 'SPEED', 'label': 'gpsSpeed', 'valueType': 'TIMESERIES'}, + ], + 'targetName': 'Test Device B1', + 'dashboard': {'id': 'dash-1', 'title': 'GPS tracker'}, + 'accuracy': 'HIGH', + 'distanceFilterMeters': 25, + 'intervalSeconds': 60, + 'maxDurationSeconds': 7200, + 'trackedBy': 'user@example.com', + }); + + expect(config.target.entityType, 'DEVICE'); + expect(config.target.id, 'abc-123'); + expect(config.targetName, 'Test Device B1'); + expect(config.dashboard?.id, 'dash-1'); + expect(config.dashboard?.title, 'GPS tracker'); + expect(config.keys.length, 3); + expect(config.keys.first.key, LiveTrackingKeyType.latitude); + expect(config.keys.first.label, 'lat'); + expect(config.keys.first.valueType, LiveTrackingValueType.attribute); + expect(config.keys.last.key, LiveTrackingKeyType.speed); + expect(config.keys.last.valueType, LiveTrackingValueType.timeseries); + expect(config.accuracy, LocationAccuracyLevel.high); + expect(config.distanceFilterMeters, 25); + expect(config.intervalSeconds, 60); + expect(config.maxDurationSeconds, 7200); + expect(config.trackedBy, 'user@example.com'); + }); + + test('applies defaults for a minimal config', () { + final config = LiveTrackingConfig.fromJson(const { + 'target': {'entityType': 'USER', 'id': 'u-1'}, + 'keys': [ + {'key': 'LATITUDE', 'label': 'latitude', 'valueType': 'ATTRIBUTE'}, + ], + }); + + expect(config.accuracy, LocationAccuracyLevel.balanced); + expect(config.distanceFilterMeters, isNull); + expect(config.intervalSeconds, isNull); + expect(config.maxDurationSeconds, isNull); + expect(config.trackedBy, isNull); + expect(config.targetName, isNull); + expect(config.dashboard, isNull); + }); + + test('dashboard with only null fields parses as null', () { + final config = LiveTrackingConfig.fromJson(const { + 'target': {'entityType': 'USER', 'id': 'u-1'}, + 'keys': [ + {'key': 'LATITUDE', 'label': 'latitude', 'valueType': 'ATTRIBUTE'}, + ], + 'dashboard': {'id': null, 'title': null}, + }); + + expect(config.dashboard, isNull); + }); + + test('unknown accuracy falls back to balanced', () { + final config = LiveTrackingConfig.fromJson(const { + 'target': {'entityType': 'USER', 'id': 'u-1'}, + 'keys': [ + {'key': 'LATITUDE', 'label': 'latitude', 'valueType': 'ATTRIBUTE'}, + ], + 'accuracy': 'ULTRA', + }); + + expect(config.accuracy, LocationAccuracyLevel.balanced); + }); + + test('unknown value type falls back to attribute', () { + final key = LiveTrackingKey.fromJson(const { + 'key': 'LATITUDE', + 'label': 'latitude', + 'valueType': 'SOMETHING_ELSE', + }); + + expect(key.valueType, LiveTrackingValueType.attribute); + }); + + test('missing target throws FormatException', () { + expect( + () => LiveTrackingConfig.fromJson(const { + 'keys': [ + {'key': 'LATITUDE', 'label': 'latitude', 'valueType': 'ATTRIBUTE'}, + ], + }), + throwsFormatException, + ); + }); + + test('malformed target throws FormatException', () { + expect( + () => LiveTrackingConfig.fromJson(const { + 'target': {'entityType': 'DEVICE'}, + }), + throwsFormatException, + ); + }); + + test('missing keys throws FormatException', () { + expect( + () => LiveTrackingConfig.fromJson(const { + 'target': {'entityType': 'DEVICE', 'id': 'abc-123'}, + }), + throwsFormatException, + ); + }); + + test('unknown key throws FormatException', () { + expect( + () => LiveTrackingConfig.fromJson(const { + 'target': {'entityType': 'DEVICE', 'id': 'abc-123'}, + 'keys': [ + {'key': 'TEMPERATURE', 'label': 'temp', 'valueType': 'TIMESERIES'}, + ], + }), + throwsFormatException, + ); + }); + + test('key without a label throws FormatException', () { + expect( + () => LiveTrackingConfig.fromJson(const { + 'target': {'entityType': 'DEVICE', 'id': 'abc-123'}, + 'keys': [ + {'key': 'LATITUDE', 'label': '', 'valueType': 'ATTRIBUTE'}, + ], + }), + throwsFormatException, + ); + }); + + test('toJson round-trips through fromJson', () { + const original = LiveTrackingConfig( + target: LiveTrackingTarget(entityType: 'DEVICE', id: 'abc-123'), + keys: [ + LiveTrackingKey( + key: LiveTrackingKeyType.latitude, + label: 'lat', + valueType: LiveTrackingValueType.attribute, + ), + LiveTrackingKey( + key: LiveTrackingKeyType.heading, + label: 'gpsHeading', + valueType: LiveTrackingValueType.timeseries, + ), + ], + targetName: 'Test Device B1', + dashboard: LiveTrackingDashboard(id: 'dash-1', title: 'GPS tracker'), + accuracy: LocationAccuracyLevel.high, + distanceFilterMeters: 25, + intervalSeconds: 60, + maxDurationSeconds: 7200, + trackedBy: 'user@example.com', + ); + + final restored = LiveTrackingConfig.fromJson(original.toJson()); + + expect(restored.target.entityType, 'DEVICE'); + expect(restored.target.id, 'abc-123'); + expect(restored.keys.length, 2); + expect(restored.keys.first.label, 'lat'); + expect(restored.keys.first.valueType, LiveTrackingValueType.attribute); + expect(restored.keys.last.key, LiveTrackingKeyType.heading); + expect(restored.keys.last.valueType, LiveTrackingValueType.timeseries); + expect(restored.targetName, 'Test Device B1'); + expect(restored.dashboard?.id, 'dash-1'); + expect(restored.dashboard?.title, 'GPS tracker'); + expect(restored.accuracy, LocationAccuracyLevel.high); + expect(restored.distanceFilterMeters, 25); + expect(restored.intervalSeconds, 60); + expect(restored.maxDurationSeconds, 7200); + expect(restored.trackedBy, 'user@example.com'); + }); +} diff --git a/test/utils/services/live_location_tracking/live_tracking_display_test.dart b/test/utils/services/live_location_tracking/live_tracking_display_test.dart new file mode 100644 index 00000000..2e9c1d99 --- /dev/null +++ b/test/utils/services/live_location_tracking/live_tracking_display_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_display.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; + +void main() { + const target = LiveTrackingTarget( + entityType: 'DEVICE', + id: 'f3eda640-42e8-11f1-af6c-63e319b36637', + ); + + test('uses resolved name when present', () { + expect(displayTargetName('My Tracker', target), 'My Tracker'); + }); + + test('falls back to type and short id when name is null', () { + expect(displayTargetName(null, target), 'DEVICE · f3eda640'); + }); + + test('falls back when name is empty/whitespace', () { + expect(displayTargetName(' ', target), 'DEVICE · f3eda640'); + }); +} diff --git a/test/utils/services/live_location_tracking/live_tracking_error_test.dart b/test/utils/services/live_location_tracking/live_tracking_error_test.dart new file mode 100644 index 00000000..0499b68a --- /dev/null +++ b/test/utils/services/live_location_tracking/live_tracking_error_test.dart @@ -0,0 +1,91 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/thingsboard_client.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_error.dart'; + +void main() { + group('LiveTrackingError.fromSaveException', () { + test('deleted target — 404 with empty server message — maps to ' + 'targetNotFound', () { + final e = ThingsboardError( + message: '404: ', + status: 404, + errorCode: ThingsBoardErrorCode.itemNotFound, + ); + + expect( + LiveTrackingError.fromSaveException(e), + LiveTrackingError.targetNotFound, + ); + }); + + test('itemNotFound code without an HTTP status maps to targetNotFound', () { + final e = ThingsboardError(errorCode: ThingsBoardErrorCode.itemNotFound); + + expect( + LiveTrackingError.fromSaveException(e), + LiveTrackingError.targetNotFound, + ); + }); + + test('connection failure maps to noConnection', () { + final e = ThingsboardError( + message: 'Unable to connect', + errorCode: ThingsBoardErrorCode.general, + ); + + expect( + LiveTrackingError.fromSaveException(e), + LiveTrackingError.noConnection, + ); + }); + + test('interceptor missing-token error — bare message, no status — maps ' + 'to unauthorized', () { + final e = ThingsboardError(message: 'Unauthorized!'); + + expect( + LiveTrackingError.fromSaveException(e), + LiveTrackingError.unauthorized, + ); + }); + + test('401 maps to unauthorized', () { + final e = ThingsboardError( + status: 401, + errorCode: ThingsBoardErrorCode.authentication, + ); + + expect( + LiveTrackingError.fromSaveException(e), + LiveTrackingError.unauthorized, + ); + }); + + test('expired JWT maps to unauthorized', () { + final e = ThingsboardError( + errorCode: ThingsBoardErrorCode.jwtTokenExpired, + ); + + expect( + LiveTrackingError.fromSaveException(e), + LiveTrackingError.unauthorized, + ); + }); + + test('an unrecognized exception maps to the generic saveFailed', () { + expect( + LiveTrackingError.fromSaveException(Exception('boom')), + LiveTrackingError.saveFailed, + ); + }); + + test('a 500 server error maps to the generic saveFailed', () { + final e = ThingsboardError(message: '500: Unknown', status: 500); + + expect( + LiveTrackingError.fromSaveException(e), + LiveTrackingError.saveFailed, + ); + }); + }); +} diff --git a/test/utils/services/live_location_tracking/live_tracking_store_test.dart b/test/utils/services/live_location_tracking/live_tracking_store_test.dart new file mode 100644 index 00000000..9d54e4c3 --- /dev/null +++ b/test/utils/services/live_location_tracking/live_tracking_store_test.dart @@ -0,0 +1,66 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/thingsboard_client.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; + +class FakeStorage implements TbStorage { + final _map = {}; + + @override + Future getItem(String key, {dynamic defaultValue}) async => + _map[key] ?? defaultValue; + + @override + Future setItem(String key, dynamic value) async => _map[key] = value; + + @override + Future deleteItem(String key) async => _map.remove(key); + + @override + Future containsKey(String key) async => _map.containsKey(key); +} + +void main() { + late FakeStorage storage; + late LiveTrackingStore store; + + setUp(() { + storage = FakeStorage(); + store = LiveTrackingStore(storage: storage, logger: TbLogger()); + }); + + final record = LastTrackingRecord( + configJson: const { + 'target': {'entityType': 'DEVICE', 'id': 'd-1'}, + 'keys': [ + {'key': 'LATITUDE', 'label': 'latitude', 'valueType': 'ATTRIBUTE'}, + ], + }, + startedAt: DateTime.fromMillisecondsSinceEpoch(1720000000000), + endReason: TrackingEndReason.interrupted, + targetName: 'My Tracker', + ); + + test('read returns null when nothing stored', () async { + expect(await store.read(), isNull); + }); + + test('write then read round-trips', () async { + await store.write(record); + final read = await store.read(); + expect(read?.targetName, 'My Tracker'); + expect(read?.config.target.id, 'd-1'); + }); + + test('clear removes the record', () async { + await store.write(record); + await store.clear(); + expect(await store.read(), isNull); + }); + + test('read returns null on corrupt json', () async { + await storage.setItem('live_tracking_last_record', '{not valid'); + expect(await store.read(), isNull); + }); +} diff --git a/test/utils/services/location/location_service_test.dart b/test/utils/services/location/location_service_test.dart new file mode 100644 index 00000000..68480e6e --- /dev/null +++ b/test/utils/services/location/location_service_test.dart @@ -0,0 +1,77 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/utils/services/location/location_service.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; + +class FakeGeolocator extends GeolocatorPlatform { + final controller = StreamController(); + + @override + Future isLocationServiceEnabled() async => true; + + @override + Future checkPermission() async => + LocationPermission.whileInUse; + + @override + Stream getPositionStream({LocationSettings? locationSettings}) => + controller.stream; +} + +void main() { + late FakeGeolocator geolocator; + late LocationService service; + + setUp(() { + geolocator = FakeGeolocator(); + service = LocationService(logger: TbLogger(), geolocator: geolocator); + }); + + Future> collectAfter(void Function() act) async { + final fixes = []; + final sub = service.positionStream().listen(fixes.add); + await pumpEventQueue(); + act(); + await pumpEventQueue(); + await sub.cancel(); + return fixes; + } + + test( + 'services-disabled stream error surfaces as LocationServicesDisabled', + () async { + final fixes = await collectAfter( + () => geolocator.controller.addError( + const LocationServiceDisabledException(), + ), + ); + + expect(fixes.single, isA()); + }, + ); + + test( + 'permission-denied stream error surfaces as LocationPermissionDenied', + () async { + final fixes = await collectAfter( + () => geolocator.controller.addError( + const PermissionDeniedException('denied'), + ), + ); + + expect(fixes.single, isA()); + }, + ); + + test('other stream errors surface as LocationFixError', () async { + final fixes = await collectAfter( + () => geolocator.controller.addError(Exception('gps glitch')), + ); + + expect(fixes.single, isA()); + expect((fixes.single as LocationFixError).message, contains('gps glitch')); + }); +} diff --git a/test/utils/services/mobile_actions/live_location_actions_test.dart b/test/utils/services/mobile_actions/live_location_actions_test.dart new file mode 100644 index 00000000..611d4f8e --- /dev/null +++ b/test/utils/services/mobile_actions/live_location_actions_test.dart @@ -0,0 +1,200 @@ +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:get_it/get_it.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_location_tracking_service.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/i_live_tracking_store.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/last_tracking_record.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_config.dart'; +import 'package:thingsboard_app/utils/services/live_location_tracking/model/live_tracking_session.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/actions/start_live_location_action.dart'; +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 FakeStore implements ILiveTrackingStore { + LastTrackingRecord? record; + + @override + Future read() async => record; + + @override + Future write(LastTrackingRecord record) async { + this.record = record; + } + + @override + Future clear() async { + record = null; + } +} + +class FakeTrackingService implements ILiveLocationTrackingService { + LiveTrackingConfig? startedWith; + bool stopped = false; + + @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; + late FakeStore store; + + setUp(() { + tracking = FakeTrackingService(); + store = FakeStore(); + GetIt.I.registerLazySingleton(() => tracking); + GetIt.I.registerLazySingleton(() => store); + }); + + 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'}, + 'keys': [ + {'key': 'LATITUDE', 'label': 'latitude', 'valueType': 'ATTRIBUTE'}, + ], + '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', + {'keys': []}, + ], 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'), + targetName: 'Test Device B1', + keys: [ + LiveTrackingKey( + key: LiveTrackingKeyType.latitude, + label: 'latitude', + valueType: LiveTrackingValueType.attribute, + ), + ], + ), + status: LiveTrackingStatus.tracking, + startedAt: DateTime.fromMillisecondsSinceEpoch(0), + ); + + final result = await StopLiveLocationAction().execute([ + 'stopLiveLocation', + ], FakeController()); + + expect(tracking.stopped, true); + final json = result.toJson(); + expect(json['hasResult'], true); + final resultJson = json['result'] as Map; + expect(resultJson['launched'], true); + final trackingInfo = resultJson['trackingInfo'] as Map; + expect(trackingInfo['targetName'], 'Test Device B1'); + expect(trackingInfo['keys'], ['latitude']); + }); + + test( + 'stop action falls back to the stored name when config has none', + () async { + tracking.session = LiveTrackingSession( + config: const LiveTrackingConfig( + target: LiveTrackingTarget(entityType: 'DEVICE', id: 'd-1'), + keys: [ + LiveTrackingKey( + key: LiveTrackingKeyType.latitude, + label: 'latitude', + valueType: LiveTrackingValueType.attribute, + ), + ], + ), + status: LiveTrackingStatus.tracking, + startedAt: DateTime.fromMillisecondsSinceEpoch(0), + ); + store.record = LastTrackingRecord( + configJson: tracking.session!.config.toJson(), + targetName: 'Resolved Name', + startedAt: DateTime.fromMillisecondsSinceEpoch(0), + endReason: TrackingEndReason.interrupted, + ); + + final result = await StopLiveLocationAction().execute([ + 'stopLiveLocation', + ], FakeController()); + + final resultJson = result.toJson()['result'] as Map; + final trackingInfo = resultJson['trackingInfo'] as Map; + expect(trackingInfo['targetName'], 'Resolved Name'); + }, + ); + + test('stop action with no session returns empty result', () async { + final result = await StopLiveLocationAction().execute([ + 'stopLiveLocation', + ], FakeController()); + + final json = result.toJson(); + expect(tracking.stopped, false); + expect(json['hasResult'], false); + expect(json['hasError'], false); + }); +} 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); + }); +}