` opening tag):
+
+```html
+
+
+
+ @if (mobileActionTypeFormGroup.get('targetEntity.type').value === targetEntityType.entityAlias) {
+
+ }
+ @if (mobileActionTypeFormGroup.get('targetEntity.type').value === targetEntityType.fromAttribute) {
+
+
+
+ }
+
+
+
+
+```
+
+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.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) {
+
+
+ @if (mobileActionTypeFormGroup.get('targetEntity.type').value === targetEntityType.entityAlias) {
+
+ }
+ @if (mobileActionTypeFormGroup.get('targetEntity.type').value === targetEntityType.fromAttribute) {
+
+
+
+ }
+
+
+
+
+
+
+ {{ '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/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/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..11359e1d
--- /dev/null
+++ b/lib/config/routes/v2/routes_config/routes/location_tracking_routes.dart
@@ -0,0 +1,15 @@
+import 'package:go_router/go_router.dart';
+import 'package:thingsboard_app/modules/location_tracking/presentation/view/live_tracking_spike_page.dart';
+
+class LocationTrackingRoutes {
+ static const liveTrackingSpike = '/liveTrackingSpike';
+}
+
+final List locationTrackingRoutes = [
+ GoRoute(
+ path: LocationTrackingRoutes.liveTrackingSpike,
+ builder: (context, state) {
+ return const LiveTrackingSpikePage();
+ },
+ ),
+];
diff --git a/lib/config/routes/v2/routes_config/routes/main_routes.dart b/lib/config/routes/v2/routes_config/routes/main_routes.dart
index 596df67b..e19b2bb5 100644
--- a/lib/config/routes/v2/routes_config/routes/main_routes.dart
+++ b/lib/config/routes/v2/routes_config/routes/main_routes.dart
@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
-import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:thingsboard_app/config/routes/v2/routes_config/routes/alarm_routes.dart';
import 'package:thingsboard_app/config/routes/v2/routes_config/routes/asset_routes.dart';
import 'package:thingsboard_app/config/routes/v2/routes_config/routes/audit_log_routes.dart';
@@ -9,6 +8,7 @@ import 'package:thingsboard_app/config/routes/v2/routes_config/routes/dashboard_
import 'package:thingsboard_app/config/routes/v2/routes_config/routes/device_routes.dart';
import 'package:thingsboard_app/config/routes/v2/routes_config/routes/esp_provisioning_routes.dart';
import 'package:thingsboard_app/config/routes/v2/routes_config/routes/home_routes.dart';
+import 'package:thingsboard_app/config/routes/v2/routes_config/routes/location_tracking_routes.dart';
import 'package:thingsboard_app/config/routes/v2/routes_config/routes/more_routes.dart';
import 'package:thingsboard_app/config/routes/v2/routes_config/routes/notification_routes.dart';
import 'package:thingsboard_app/config/routes/v2/routes_config/routes/profile_routes.dart';
@@ -28,6 +28,7 @@ final allMainPages = [
...urlRoutes,
...espProvisioningRoutes,
...profileRoutes,
+ ...locationTrackingRoutes,
];
List getMainRoutes() {
return [
diff --git a/lib/locator.dart b/lib/locator.dart
index 0fb8b408..38949385 100644
--- a/lib/locator.dart
+++ b/lib/locator.dart
@@ -18,6 +18,8 @@ import 'package:thingsboard_app/utils/services/loading_service/i_loading_service
import 'package:thingsboard_app/utils/services/loading_service/loading_service.dart';
import 'package:thingsboard_app/utils/services/local_database/i_local_database_service.dart';
import 'package:thingsboard_app/utils/services/local_database/local_database_service.dart';
+import 'package:thingsboard_app/utils/services/location/i_location_service.dart';
+import 'package:thingsboard_app/utils/services/location/location_service.dart';
import 'package:thingsboard_app/utils/services/notification_service.dart';
import 'package:thingsboard_app/utils/services/overlay_service/i_overlay_service.dart';
import 'package:thingsboard_app/utils/services/overlay_service/overlay_service.dart';
@@ -54,6 +56,9 @@ Future setUpRootDependencies() async {
..registerLazySingleton(() => TbImageGalleryService())
..registerLazySingleton(() => ThingsboardAppRouter(overlayService: getIt()))
..registerLazySingleton(() => deviceInfoService)
+ ..registerLazySingleton(
+ () => LocationService(logger: getIt()),
+ )
// ..registerLazySingleton(() => TbContext())
..registerSingletonAsync(() async {
final client = TbClientService();
diff --git a/lib/modules/location_tracking/presentation/view/live_tracking_spike_page.dart b/lib/modules/location_tracking/presentation/view/live_tracking_spike_page.dart
new file mode 100644
index 00000000..a31142d6
--- /dev/null
+++ b/lib/modules/location_tracking/presentation/view/live_tracking_spike_page.dart
@@ -0,0 +1,283 @@
+import 'dart:async';
+import 'dart:convert';
+
+import 'package:flutter/material.dart';
+import 'package:thingsboard_app/locator.dart';
+import 'package:thingsboard_app/utils/services/location/i_location_service.dart';
+import 'package:thingsboard_app/utils/services/location/model/geo_position.dart';
+import 'package:thingsboard_app/utils/services/location/model/location_fix.dart';
+import 'package:thingsboard_app/utils/services/location/model/location_stream_settings.dart';
+import 'package:thingsboard_app/utils/services/tb_client_service/i_tb_client_service.dart';
+
+/// Phase 1a spike (see docs/superpowers/specs/2026-07-03-gps-tracking-design.md):
+/// validates background/foreground live tracking and REST telemetry saves on
+/// real devices before the production tracking service is built. Debug-only
+/// entry point on the More page; throwaway UI by design.
+class LiveTrackingSpikePage extends StatefulWidget {
+ const LiveTrackingSpikePage({super.key});
+
+ @override
+ State createState() => _LiveTrackingSpikePageState();
+}
+
+class _SpikeEvent {
+ _SpikeEvent(this.message, {this.isError = false}) : time = DateTime.now();
+
+ final DateTime time;
+ final String message;
+ final bool isError;
+}
+
+class _LiveTrackingSpikePageState extends State {
+ static const _maxEvents = 200;
+
+ bool _backgroundMode = true;
+ bool _saveTelemetry = true;
+
+ StreamSubscription? _subscription;
+ Timer? _ticker;
+ DateTime? _startedAt;
+ int _fixCount = 0;
+ int _savedCount = 0;
+ int _saveErrorCount = 0;
+ GeoPosition? _lastFix;
+ final List<_SpikeEvent> _events = [];
+
+ bool get _isRunning => _subscription != null;
+
+ @override
+ void dispose() {
+ _ticker?.cancel();
+ _subscription?.cancel();
+ super.dispose();
+ }
+
+ void _start() {
+ final settings = LocationStreamSettings(
+ background:
+ _backgroundMode
+ ? const BackgroundTrackingConfig(
+ notificationTitle: 'ThingsBoard live tracking',
+ notificationText: 'Sharing phone location (spike)',
+ )
+ : null,
+ );
+
+ _startedAt = DateTime.now();
+ _fixCount = 0;
+ _savedCount = 0;
+ _saveErrorCount = 0;
+ _lastFix = null;
+ _events.clear();
+ _logEvent(
+ 'Started (${_backgroundMode ? 'background' : 'foreground'} mode, '
+ 'telemetry ${_saveTelemetry ? 'on' : 'off'})',
+ );
+
+ _subscription = getIt()
+ .positionStream(settings: settings)
+ .listen(_onFix, onDone: _stop);
+ _ticker = Timer.periodic(const Duration(seconds: 1), (_) {
+ if (mounted) setState(() {});
+ });
+ setState(() {});
+ }
+
+ void _stop() {
+ _ticker?.cancel();
+ _ticker = null;
+ _subscription?.cancel();
+ _subscription = null;
+ _logEvent('Stopped');
+ if (mounted) setState(() {});
+ }
+
+ void _onFix(LocationFix fix) {
+ switch (fix) {
+ case LocationSuccess(:final position):
+ _fixCount++;
+ _lastFix = position;
+ _logEvent(
+ '${position.latitude.toStringAsFixed(6)}, '
+ '${position.longitude.toStringAsFixed(6)} '
+ '(±${position.accuracy.toStringAsFixed(0)} m)',
+ );
+ if (_saveTelemetry) {
+ unawaited(_saveFix(position));
+ }
+ case LocationServicesDisabled():
+ _logEvent('Location services disabled', isError: true);
+ _stop();
+ case LocationPermissionDenied():
+ _logEvent('Location permission denied', isError: true);
+ _stop();
+ case LocationPermissionDeniedForever():
+ _logEvent('Location permission permanently denied', isError: true);
+ _stop();
+ case LocationFixError(:final message):
+ _logEvent('Fix error: $message', isError: true);
+ }
+ if (mounted) setState(() {});
+ }
+
+ Future _saveFix(GeoPosition position) async {
+ try {
+ final client = getIt().client;
+ final userId = client.getAuthUser()?.userId;
+ if (userId == null) {
+ throw Exception('No authenticated user');
+ }
+
+ await client.getTelemetryControllerApi().saveEntityTelemetry(
+ entityType: 'USER',
+ entityId: userId,
+ scope: 'ANY',
+ body: jsonEncode({
+ 'ts': (position.timestamp ?? DateTime.now()).millisecondsSinceEpoch,
+ 'values': {
+ 'latitude': position.latitude,
+ 'longitude': position.longitude,
+ 'gpsAccuracy': position.accuracy,
+ },
+ }),
+ );
+ _savedCount++;
+ } catch (e) {
+ _saveErrorCount++;
+ _logEvent('Telemetry save failed: $e', isError: true);
+ }
+ if (mounted) setState(() {});
+ }
+
+ void _logEvent(String message, {bool isError = false}) {
+ _events.insert(0, _SpikeEvent(message, isError: isError));
+ if (_events.length > _maxEvents) {
+ _events.removeLast();
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(title: const Text('GPS tracking spike')),
+ body: Column(
+ children: [
+ SwitchListTile(
+ title: const Text('Background tracking'),
+ subtitle: const Text(
+ 'Foreground service on Android, background mode on iOS',
+ ),
+ value: _backgroundMode,
+ onChanged:
+ _isRunning
+ ? null
+ : (value) => setState(() => _backgroundMode = value),
+ ),
+ SwitchListTile(
+ title: const Text('Save telemetry to my user entity'),
+ subtitle: const Text('latitude / longitude / gpsAccuracy'),
+ value: _saveTelemetry,
+ onChanged:
+ _isRunning
+ ? null
+ : (value) => setState(() => _saveTelemetry = value),
+ ),
+ Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
+ child: Row(
+ children: [
+ Expanded(
+ child: FilledButton.icon(
+ onPressed: _isRunning ? _stop : _start,
+ icon: Icon(_isRunning ? Icons.stop : Icons.play_arrow),
+ label: Text(_isRunning ? 'Stop' : 'Start'),
+ ),
+ ),
+ ],
+ ),
+ ),
+ _StatsCard(
+ isRunning: _isRunning,
+ startedAt: _startedAt,
+ fixCount: _fixCount,
+ savedCount: _savedCount,
+ saveErrorCount: _saveErrorCount,
+ lastFix: _lastFix,
+ ),
+ const Divider(height: 1),
+ Expanded(
+ child: ListView.builder(
+ itemCount: _events.length,
+ itemBuilder: (context, index) {
+ final event = _events[index];
+ return ListTile(
+ dense: true,
+ leading: Text(
+ TimeOfDay.fromDateTime(event.time).format(context),
+ ),
+ title: Text(
+ event.message,
+ style:
+ event.isError
+ ? TextStyle(
+ color: Theme.of(context).colorScheme.error,
+ )
+ : null,
+ ),
+ );
+ },
+ ),
+ ),
+ ],
+ ),
+ );
+ }
+}
+
+class _StatsCard extends StatelessWidget {
+ const _StatsCard({
+ required this.isRunning,
+ required this.startedAt,
+ required this.fixCount,
+ required this.savedCount,
+ required this.saveErrorCount,
+ required this.lastFix,
+ });
+
+ final bool isRunning;
+ final DateTime? startedAt;
+ final int fixCount;
+ final int savedCount;
+ final int saveErrorCount;
+ final GeoPosition? lastFix;
+
+ @override
+ Widget build(BuildContext context) {
+ final elapsed =
+ startedAt == null ? null : DateTime.now().difference(startedAt!);
+ final lastFixTime = lastFix?.timestamp;
+ final lastFixAge =
+ lastFixTime == null ? null : DateTime.now().difference(lastFixTime);
+
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: 16),
+ child: Text(
+ [
+ if (isRunning) 'RUNNING' else 'STOPPED',
+ if (elapsed != null) 'elapsed: ${_format(elapsed)}',
+ 'fixes: $fixCount',
+ 'saved: $savedCount',
+ if (saveErrorCount > 0) 'save errors: $saveErrorCount',
+ if (lastFixAge != null) 'last fix: ${_format(lastFixAge)} ago',
+ ].join(' · '),
+ style: Theme.of(context).textTheme.bodyMedium,
+ ),
+ );
+ }
+
+ String _format(Duration d) {
+ final minutes = d.inMinutes;
+ final seconds = d.inSeconds % 60;
+ return minutes > 0 ? '${minutes}m ${seconds}s' : '${seconds}s';
+ }
+}
diff --git a/lib/modules/more/more_page.dart b/lib/modules/more/more_page.dart
index 29d3a54b..d1fc488b 100644
--- a/lib/modules/more/more_page.dart
+++ b/lib/modules/more/more_page.dart
@@ -1,8 +1,10 @@
+import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:go_router/go_router.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:thingsboard_app/config/routes/v2/routes_config/routes/location_tracking_routes.dart';
import 'package:thingsboard_app/config/themes/app_colors.dart';
import 'package:thingsboard_app/core/auth/login/provider/login_provider.dart';
import 'package:thingsboard_app/core/usecases/user_details_usecase.dart';
@@ -45,40 +47,53 @@ class MorePage extends HookConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ProfileWidget(userDetails: userDetails, user: login.user),
- if(items.isNotEmpty)
- ...[Divider(
- color: Colors.black.withValues(alpha: .05),
- thickness: 1,
- height: 0,
- ),
- Flexible(
- child: SingleChildScrollView(
- child: Column(
- children:
- items
- .map(
- (e) => MoreMenuItemWidget(
- TbMainNavigationItem(
- title: NavigationHelper.getLocalizedTitle(
- context,
- e.id,
- e.path,
- e.title,
+ if (items.isNotEmpty) ...[
+ Divider(
+ color: Colors.black.withValues(alpha: .05),
+ thickness: 1,
+ height: 0,
+ ),
+ Flexible(
+ child: SingleChildScrollView(
+ child: Column(
+ children:
+ items
+ .map(
+ (e) => MoreMenuItemWidget(
+ TbMainNavigationItem(
+ title: NavigationHelper.getLocalizedTitle(
+ context,
+ e.id,
+ e.path,
+ e.title,
+ ),
+ icon: e.icon,
+ path: e.path,
+ showAdditionalIcon:
+ e.showNotificationBadge,
),
- icon: e.icon,
- path: e.path,
- showAdditionalIcon: e.showNotificationBadge,
+ onTap: () {
+ context.push(e.path);
+ },
),
- onTap: () {
- context.push(e.path);
- },
- ),
- )
- .toList(),
+ )
+ .toList(),
+ ),
),
),
- ),
- ],
+ ],
+ // Debug-only entry to the phase 1a GPS tracking spike page.
+ if (kDebugMode)
+ MoreMenuItemWidget(
+ const TbMainNavigationItem(
+ title: 'GPS tracking spike',
+ icon: Icons.gps_fixed,
+ path: LocationTrackingRoutes.liveTrackingSpike,
+ ),
+ onTap: () {
+ context.push(LocationTrackingRoutes.liveTrackingSpike);
+ },
+ ),
Divider(
color: Colors.black.withValues(alpha: .05),
thickness: 1,
@@ -92,8 +107,8 @@ class MorePage extends HookConsumerWidget {
),
showTrailing: false,
color: AppColors.textError,
- onTap: () async {
- await ref.read(loginProvider.notifier).logout();
+ onTap: () async {
+ await ref.read(loginProvider.notifier).logout();
},
),
],
diff --git a/lib/utils/services/location/i_location_service.dart b/lib/utils/services/location/i_location_service.dart
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..2f32670e
--- /dev/null
+++ b/lib/utils/services/location/location_service.dart
@@ -0,0 +1,135 @@
+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 LocationFixError(e.toString());
+ }
+ }
+
+ @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(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,
+ );
+}
diff --git a/lib/utils/services/location/model/geo_position.dart b/lib/utils/services/location/model/geo_position.dart
new file mode 100644
index 00000000..645c8cc3
--- /dev/null
+++ b/lib/utils/services/location/model/geo_position.dart
@@ -0,0 +1,15 @@
+import 'package:freezed_annotation/freezed_annotation.dart';
+
+part 'geo_position.freezed.dart';
+
+/// Plugin-agnostic GPS position. Keeps `package:geolocator`'s `Position`
+/// type from leaking past the location service boundary.
+@freezed
+abstract class GeoPosition with _$GeoPosition {
+ const factory GeoPosition({
+ required double latitude,
+ required double longitude,
+ required double accuracy,
+ DateTime? timestamp,
+ }) = _GeoPosition;
+}
diff --git a/lib/utils/services/location/model/geo_position.freezed.dart b/lib/utils/services/location/model/geo_position.freezed.dart
new file mode 100644
index 00000000..4a5aefc3
--- /dev/null
+++ b/lib/utils/services/location/model/geo_position.freezed.dart
@@ -0,0 +1,280 @@
+// GENERATED CODE - DO NOT MODIFY BY HAND
+// coverage:ignore-file
+// ignore_for_file: type=lint
+// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
+
+part of 'geo_position.dart';
+
+// **************************************************************************
+// FreezedGenerator
+// **************************************************************************
+
+// dart format off
+T _$identity(T value) => value;
+/// @nodoc
+mixin _$GeoPosition {
+
+ double get latitude; double get longitude; double get accuracy; DateTime? get timestamp;
+/// Create a copy of GeoPosition
+/// with the given fields replaced by the non-null parameter values.
+@JsonKey(includeFromJson: false, includeToJson: false)
+@pragma('vm:prefer-inline')
+$GeoPositionCopyWith get copyWith => _$GeoPositionCopyWithImpl(this as GeoPosition, _$identity);
+
+
+
+@override
+bool operator ==(Object other) {
+ return identical(this, other) || (other.runtimeType == runtimeType&&other is GeoPosition&&(identical(other.latitude, latitude) || other.latitude == latitude)&&(identical(other.longitude, longitude) || other.longitude == longitude)&&(identical(other.accuracy, accuracy) || other.accuracy == accuracy)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp));
+}
+
+
+@override
+int get hashCode => Object.hash(runtimeType,latitude,longitude,accuracy,timestamp);
+
+@override
+String toString() {
+ return 'GeoPosition(latitude: $latitude, longitude: $longitude, accuracy: $accuracy, timestamp: $timestamp)';
+}
+
+
+}
+
+/// @nodoc
+abstract mixin class $GeoPositionCopyWith<$Res> {
+ factory $GeoPositionCopyWith(GeoPosition value, $Res Function(GeoPosition) _then) = _$GeoPositionCopyWithImpl;
+@useResult
+$Res call({
+ double latitude, double longitude, double accuracy, DateTime? timestamp
+});
+
+
+
+
+}
+/// @nodoc
+class _$GeoPositionCopyWithImpl<$Res>
+ implements $GeoPositionCopyWith<$Res> {
+ _$GeoPositionCopyWithImpl(this._self, this._then);
+
+ final GeoPosition _self;
+ final $Res Function(GeoPosition) _then;
+
+/// Create a copy of GeoPosition
+/// with the given fields replaced by the non-null parameter values.
+@pragma('vm:prefer-inline') @override $Res call({Object? latitude = null,Object? longitude = null,Object? accuracy = null,Object? timestamp = freezed,}) {
+ return _then(_self.copyWith(
+latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable
+as double,longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable
+as double,accuracy: null == accuracy ? _self.accuracy : accuracy // ignore: cast_nullable_to_non_nullable
+as double,timestamp: freezed == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable
+as DateTime?,
+ ));
+}
+
+}
+
+
+/// Adds pattern-matching-related methods to [GeoPosition].
+extension GeoPositionPatterns on GeoPosition {
+/// A variant of `map` that fallback to returning `orElse`.
+///
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case final Subclass value:
+/// return ...;
+/// case _:
+/// return orElse();
+/// }
+/// ```
+
+@optionalTypeArgs TResult maybeMap(TResult Function( _GeoPosition value)? $default,{required TResult orElse(),}){
+final _that = this;
+switch (_that) {
+case _GeoPosition() when $default != null:
+return $default(_that);case _:
+ return orElse();
+
+}
+}
+/// A `switch`-like method, using callbacks.
+///
+/// Callbacks receives the raw object, upcasted.
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case final Subclass value:
+/// return ...;
+/// case final Subclass2 value:
+/// return ...;
+/// }
+/// ```
+
+@optionalTypeArgs TResult map(TResult Function( _GeoPosition value) $default,){
+final _that = this;
+switch (_that) {
+case _GeoPosition():
+return $default(_that);case _:
+ throw StateError('Unexpected subclass');
+
+}
+}
+/// A variant of `map` that fallback to returning `null`.
+///
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case final Subclass value:
+/// return ...;
+/// case _:
+/// return null;
+/// }
+/// ```
+
+@optionalTypeArgs TResult? mapOrNull(TResult? Function( _GeoPosition value)? $default,){
+final _that = this;
+switch (_that) {
+case _GeoPosition() when $default != null:
+return $default(_that);case _:
+ return null;
+
+}
+}
+/// A variant of `when` that fallback to an `orElse` callback.
+///
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case Subclass(:final field):
+/// return ...;
+/// case _:
+/// return orElse();
+/// }
+/// ```
+
+@optionalTypeArgs TResult maybeWhen(TResult Function( double latitude, double longitude, double accuracy, DateTime? timestamp)? $default,{required TResult orElse(),}) {final _that = this;
+switch (_that) {
+case _GeoPosition() when $default != null:
+return $default(_that.latitude,_that.longitude,_that.accuracy,_that.timestamp);case _:
+ return orElse();
+
+}
+}
+/// A `switch`-like method, using callbacks.
+///
+/// As opposed to `map`, this offers destructuring.
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case Subclass(:final field):
+/// return ...;
+/// case Subclass2(:final field2):
+/// return ...;
+/// }
+/// ```
+
+@optionalTypeArgs TResult when(TResult Function( double latitude, double longitude, double accuracy, DateTime? timestamp) $default,) {final _that = this;
+switch (_that) {
+case _GeoPosition():
+return $default(_that.latitude,_that.longitude,_that.accuracy,_that.timestamp);case _:
+ throw StateError('Unexpected subclass');
+
+}
+}
+/// A variant of `when` that fallback to returning `null`
+///
+/// It is equivalent to doing:
+/// ```dart
+/// switch (sealedClass) {
+/// case Subclass(:final field):
+/// return ...;
+/// case _:
+/// return null;
+/// }
+/// ```
+
+@optionalTypeArgs TResult? whenOrNull(TResult? Function( double latitude, double longitude, double accuracy, DateTime? timestamp)? $default,) {final _that = this;
+switch (_that) {
+case _GeoPosition() when $default != null:
+return $default(_that.latitude,_that.longitude,_that.accuracy,_that.timestamp);case _:
+ return null;
+
+}
+}
+
+}
+
+/// @nodoc
+
+
+class _GeoPosition implements GeoPosition {
+ const _GeoPosition({required this.latitude, required this.longitude, required this.accuracy, this.timestamp});
+
+
+@override final double latitude;
+@override final double longitude;
+@override final double accuracy;
+@override final DateTime? timestamp;
+
+/// Create a copy of GeoPosition
+/// with the given fields replaced by the non-null parameter values.
+@override @JsonKey(includeFromJson: false, includeToJson: false)
+@pragma('vm:prefer-inline')
+_$GeoPositionCopyWith<_GeoPosition> get copyWith => __$GeoPositionCopyWithImpl<_GeoPosition>(this, _$identity);
+
+
+
+@override
+bool operator ==(Object other) {
+ return identical(this, other) || (other.runtimeType == runtimeType&&other is _GeoPosition&&(identical(other.latitude, latitude) || other.latitude == latitude)&&(identical(other.longitude, longitude) || other.longitude == longitude)&&(identical(other.accuracy, accuracy) || other.accuracy == accuracy)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp));
+}
+
+
+@override
+int get hashCode => Object.hash(runtimeType,latitude,longitude,accuracy,timestamp);
+
+@override
+String toString() {
+ return 'GeoPosition(latitude: $latitude, longitude: $longitude, accuracy: $accuracy, timestamp: $timestamp)';
+}
+
+
+}
+
+/// @nodoc
+abstract mixin class _$GeoPositionCopyWith<$Res> implements $GeoPositionCopyWith<$Res> {
+ factory _$GeoPositionCopyWith(_GeoPosition value, $Res Function(_GeoPosition) _then) = __$GeoPositionCopyWithImpl;
+@override @useResult
+$Res call({
+ double latitude, double longitude, double accuracy, DateTime? timestamp
+});
+
+
+
+
+}
+/// @nodoc
+class __$GeoPositionCopyWithImpl<$Res>
+ implements _$GeoPositionCopyWith<$Res> {
+ __$GeoPositionCopyWithImpl(this._self, this._then);
+
+ final _GeoPosition _self;
+ final $Res Function(_GeoPosition) _then;
+
+/// Create a copy of GeoPosition
+/// with the given fields replaced by the non-null parameter values.
+@override @pragma('vm:prefer-inline') $Res call({Object? latitude = null,Object? longitude = null,Object? accuracy = null,Object? timestamp = freezed,}) {
+ return _then(_GeoPosition(
+latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable
+as double,longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable
+as double,accuracy: null == accuracy ? _self.accuracy : accuracy // ignore: cast_nullable_to_non_nullable
+as double,timestamp: freezed == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable
+as DateTime?,
+ ));
+}
+
+
+}
+
+// dart format on
diff --git a/lib/utils/services/location/model/location_fix.dart b/lib/utils/services/location/model/location_fix.dart
new file mode 100644
index 00000000..84315e8b
--- /dev/null
+++ b/lib/utils/services/location/model/location_fix.dart
@@ -0,0 +1,38 @@
+import 'package:thingsboard_app/utils/services/location/model/geo_position.dart';
+
+/// Outcome of a location request. Exhaustively matched with `switch`, so every
+/// caller is forced by the compiler to handle each failure mode.
+sealed class LocationFix {
+ const LocationFix();
+}
+
+/// A position was obtained.
+final class LocationSuccess extends LocationFix {
+ const LocationSuccess(this.position);
+
+ final GeoPosition position;
+}
+
+/// The OS location services are turned off. Caller may prompt the user and
+/// call [ILocationService.openLocationSettings].
+final class LocationServicesDisabled extends LocationFix {
+ const LocationServicesDisabled();
+}
+
+/// Permission was denied but can be requested again later.
+final class LocationPermissionDenied extends LocationFix {
+ const LocationPermissionDenied();
+}
+
+/// Permission was permanently denied. Caller must deep-link via
+/// [ILocationService.openAppSettings].
+final class LocationPermissionDeniedForever extends LocationFix {
+ const LocationPermissionDeniedForever();
+}
+
+/// An unexpected platform error occurred.
+final class LocationFixError extends LocationFix {
+ const LocationFixError(this.message);
+
+ final String message;
+}
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_live_location_action.dart b/lib/utils/services/mobile_actions/actions/get_live_location_action.dart
new file mode 100644
index 00000000..7b1ea67e
--- /dev/null
+++ b/lib/utils/services/mobile_actions/actions/get_live_location_action.dart
@@ -0,0 +1,83 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_inappwebview/flutter_inappwebview.dart';
+import 'package:thingsboard_app/config/routes/v2/router_2.dart';
+import 'package:thingsboard_app/locator.dart';
+import 'package:thingsboard_app/utils/services/location/i_location_service.dart';
+import 'package:thingsboard_app/utils/services/location/model/location_fix.dart';
+import 'package:thingsboard_app/utils/services/mobile_actions/actions/location_action_result_mapper.dart';
+import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action.dart';
+import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_result.dart';
+import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_type.dart';
+
+/// Live counterpart of [WidgetMobileActionType.getLocation]: subscribes to
+/// [ILocationService.positionStream] and reflects the device position as it
+/// updates, rather than returning a single fix.
+///
+/// NOTE: the dialog below is a placeholder visualization used to exercise the
+/// live stream — the production UI is not decided yet. The action is registered
+/// under [WidgetMobileActionType.getLiveLocation] but is not yet triggered by
+/// any dashboard widget.
+class GetLiveLocationAction extends MobileAction
+ with LocationActionResultMapper {
+ @override
+ Future execute(
+ List args,
+ InAppWebViewController controller,
+ ) async {
+ try {
+ final service = getIt();
+ final context = globalNavigatorKey.currentContext!;
+
+ // Most recent successful fix, handed back when the dialog is dismissed.
+ LocationFix? lastFix;
+
+ await showDialog(
+ context: context,
+ builder: (dialogContext) {
+ return AlertDialog(
+ title: const Text('Live location'),
+ content: StreamBuilder(
+ stream: service.positionStream(),
+ builder: (ctx, snapshot) {
+ final fix = snapshot.data;
+ if (fix is LocationSuccess) {
+ lastFix = fix;
+ }
+ return Text(_describe(fix));
+ },
+ ),
+ actions: [
+ TextButton(
+ onPressed: () => Navigator.of(dialogContext).pop(),
+ child: const Text('Close'),
+ ),
+ ],
+ );
+ },
+ );
+
+ return lastFix == null
+ ? WidgetMobileActionResult.emptyResult()
+ : mapLocationFixToResult(lastFix!);
+ } catch (e) {
+ return handleError(e);
+ }
+ }
+
+ String _describe(LocationFix? fix) => switch (fix) {
+ null => 'Waiting for first GPS fix…',
+ LocationSuccess(:final position) =>
+ 'lat: ${position.latitude.toStringAsFixed(6)}\n'
+ 'lng: ${position.longitude.toStringAsFixed(6)}\n'
+ 'accuracy: ${position.accuracy.toStringAsFixed(1)} m\n'
+ 'updated: ${position.timestamp}',
+ LocationServicesDisabled() => 'Location services are disabled.',
+ LocationPermissionDenied() => 'Location permission denied.',
+ LocationPermissionDeniedForever() =>
+ 'Location permission permanently denied.',
+ LocationFixError(:final message) => 'Error: $message',
+ };
+
+ @override
+ WidgetMobileActionType get type => WidgetMobileActionType.getLiveLocation;
+}
diff --git a/lib/utils/services/mobile_actions/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..3336d8ab
--- /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
+/// both the one-shot `GetLocationAction` and the live `GetLiveLocationAction`
+/// so the success/failure result contract stays identical between them.
+mixin LocationActionResultMapper {
+ WidgetMobileActionResult mapLocationFixToResult(LocationFix fix) {
+ return switch (fix) {
+ LocationSuccess(:final position) =>
+ WidgetMobileActionResult.successResult(
+ MobileActionResult.location(
+ position.latitude,
+ position.longitude,
+ 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/mobile_action_result.dart b/lib/utils/services/mobile_actions/mobile_action_result.dart
index 992b11c7..eeffaf57 100644
--- a/lib/utils/services/mobile_actions/mobile_action_result.dart
+++ b/lib/utils/services/mobile_actions/mobile_action_result.dart
@@ -3,7 +3,8 @@ import 'package:thingsboard_app/utils/services/mobile_actions/results/image_resu
import 'package:thingsboard_app/utils/services/mobile_actions/results/launch_result.dart';
import 'package:thingsboard_app/utils/services/mobile_actions/results/location_result.dart';
import 'package:thingsboard_app/utils/services/mobile_actions/results/qr_code_result.dart';
-abstract class MobileActionResult {
+
+abstract class MobileActionResult {
MobileActionResult();
factory MobileActionResult.launched(bool launched) {
@@ -18,8 +19,13 @@ abstract class MobileActionResult {
return QrCodeResult(code, format);
}
- factory MobileActionResult.location(num latitude, num longitude) {
- return LocationResult(latitude, longitude);
+ factory MobileActionResult.location(
+ num latitude,
+ num longitude, {
+ num? accuracy,
+ int? ts,
+ }) {
+ return LocationResult(latitude, longitude, accuracy: accuracy, ts: ts);
}
factory MobileActionResult.provisioning(String deviceName) {
diff --git a/lib/utils/services/mobile_actions/results/location_result.dart b/lib/utils/services/mobile_actions/results/location_result.dart
index fdbd968f..335431e8 100644
--- a/lib/utils/services/mobile_actions/results/location_result.dart
+++ b/lib/utils/services/mobile_actions/results/location_result.dart
@@ -1,16 +1,23 @@
import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action_result.dart';
class LocationResult extends MobileActionResult {
-
- LocationResult(this.latitude, this.longitude);
+ LocationResult(this.latitude, this.longitude, {this.accuracy, this.ts});
num latitude;
num longitude;
+ num? accuracy;
+ int? ts;
@override
Map toJson() {
final json = super.toJson();
json['latitude'] = latitude;
json['longitude'] = longitude;
+ if (accuracy != null) {
+ json['accuracy'] = accuracy;
+ }
+ if (ts != null) {
+ json['ts'] = ts;
+ }
return json;
}
}
diff --git a/lib/utils/services/mobile_actions/widget_action_handler.dart b/lib/utils/services/mobile_actions/widget_action_handler.dart
index efb881b2..4d49afbc 100644
--- a/lib/utils/services/mobile_actions/widget_action_handler.dart
+++ b/lib/utils/services/mobile_actions/widget_action_handler.dart
@@ -1,6 +1,6 @@
-
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:thingsboard_app/utils/services/mobile_actions/actions/device_provisioning_action.dart';
+import 'package:thingsboard_app/utils/services/mobile_actions/actions/get_live_location_action.dart';
import 'package:thingsboard_app/utils/services/mobile_actions/actions/get_location_action.dart';
import 'package:thingsboard_app/utils/services/mobile_actions/actions/make_phone_call_action.dart';
import 'package:thingsboard_app/utils/services/mobile_actions/actions/scan_qr_action.dart';
@@ -12,8 +12,9 @@ import 'package:thingsboard_app/utils/services/mobile_actions/actions/take_scree
import 'package:thingsboard_app/utils/services/mobile_actions/actions/unknown_action.dart';
import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_result.dart';
import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_type.dart';
+
class WidgetActionHandler {
- static final actions = [
+ static final actions = [
DeviceProvisioningAction(),
UnknownAction(),
ShowMapLocationAction(),
@@ -23,6 +24,7 @@ class WidgetActionHandler {
ScanQrAction(),
MakePhoneCallAction(),
GetLocationAction(),
+ GetLiveLocationAction(),
TakeScreenshotAction(),
];
Future