From 35ddc8bba0eaea5f35a1b2d21fd90976df50b162 Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 10 Jun 2026 18:39:35 +0300 Subject: [PATCH 1/3] feat(location): add reusable LocationService with on-demand and streaming GPS access --- lib/locator.dart | 5 + .../services/location/i_location_service.dart | 22 ++ .../services/location/location_service.dart | 98 ++++++ .../services/location/model/geo_position.dart | 15 + .../location/model/geo_position.freezed.dart | 280 ++++++++++++++++++ .../services/location/model/location_fix.dart | 38 +++ 6 files changed, 458 insertions(+) create mode 100644 lib/utils/services/location/i_location_service.dart create mode 100644 lib/utils/services/location/location_service.dart create mode 100644 lib/utils/services/location/model/geo_position.dart create mode 100644 lib/utils/services/location/model/geo_position.freezed.dart create mode 100644 lib/utils/services/location/model/location_fix.dart diff --git a/lib/locator.dart b/lib/locator.dart index 0fb8b408..38949385 100644 --- a/lib/locator.dart +++ b/lib/locator.dart @@ -18,6 +18,8 @@ import 'package:thingsboard_app/utils/services/loading_service/i_loading_service import 'package:thingsboard_app/utils/services/loading_service/loading_service.dart'; import 'package:thingsboard_app/utils/services/local_database/i_local_database_service.dart'; import 'package:thingsboard_app/utils/services/local_database/local_database_service.dart'; +import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; +import 'package:thingsboard_app/utils/services/location/location_service.dart'; import 'package:thingsboard_app/utils/services/notification_service.dart'; import 'package:thingsboard_app/utils/services/overlay_service/i_overlay_service.dart'; import 'package:thingsboard_app/utils/services/overlay_service/overlay_service.dart'; @@ -54,6 +56,9 @@ Future setUpRootDependencies() async { ..registerLazySingleton(() => TbImageGalleryService()) ..registerLazySingleton(() => ThingsboardAppRouter(overlayService: getIt())) ..registerLazySingleton(() => deviceInfoService) + ..registerLazySingleton( + () => LocationService(logger: getIt()), + ) // ..registerLazySingleton(() => TbContext()) ..registerSingletonAsync(() async { final client = TbClientService(); diff --git a/lib/utils/services/location/i_location_service.dart b/lib/utils/services/location/i_location_service.dart new file mode 100644 index 00000000..f6d1b291 --- /dev/null +++ b/lib/utils/services/location/i_location_service.dart @@ -0,0 +1,22 @@ +import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; + +/// Single entry point for GPS access across the app. Implementations own all +/// permission / service-enabled handling so callers never touch the geolocator +/// plugin directly. +abstract interface class ILocationService { + /// Resolves a single current position, performing the full permission and + /// service-enabled checks internally. + Future getCurrentPosition(); + + /// Foreground live position updates. Pre-checks availability, then relays + /// each update as a [LocationSuccess]. Emits a terminal failure [LocationFix] + /// (and stops) if location is unavailable. Subscribers must cancel their + /// [StreamSubscription] when done (Riverpod/Bloc disposal handles this). + Stream positionStream({double distanceFilterMeters = 0}); + + /// Opens the OS location settings screen. Returns true if it was opened. + Future openLocationSettings(); + + /// Opens this app's settings screen (for permanently-denied permission). + Future openAppSettings(); +} diff --git a/lib/utils/services/location/location_service.dart b/lib/utils/services/location/location_service.dart new file mode 100644 index 00000000..04fef0b2 --- /dev/null +++ b/lib/utils/services/location/location_service.dart @@ -0,0 +1,98 @@ +import 'dart:async'; + +import 'package:geolocator/geolocator.dart'; +import 'package:thingsboard_app/core/logger/tb_logger.dart'; +import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; +import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; + +class LocationService implements ILocationService { + LocationService({required TbLogger logger, GeolocatorPlatform? geolocator}) + : _log = logger, + _geolocator = geolocator ?? GeolocatorPlatform.instance; + + final TbLogger _log; + final GeolocatorPlatform _geolocator; + + @override + Future getCurrentPosition() async { + try { + final unavailable = await _ensureAvailable(); + if (unavailable != null) { + return unavailable; + } + + final position = await _geolocator.getCurrentPosition( + locationSettings: const LocationSettings( + accuracy: LocationAccuracy.high, + ), + ); + return LocationSuccess(_toGeoPosition(position)); + } catch (e, s) { + _log.error('LocationService.getCurrentPosition failed', e, s); + return LocationFixError(e.toString()); + } + } + + @override + Stream positionStream({double distanceFilterMeters = 0}) async* { + final unavailable = await _ensureAvailable(); + if (unavailable != null) { + yield unavailable; + return; + } + + final raw = _geolocator.getPositionStream( + locationSettings: LocationSettings( + accuracy: LocationAccuracy.high, + distanceFilter: distanceFilterMeters.round(), + ), + ); + + yield* raw.transform( + StreamTransformer.fromHandlers( + handleData: + (position, sink) => + sink.add(LocationSuccess(_toGeoPosition(position))), + handleError: (e, s, sink) { + _log.error('LocationService.positionStream error', e, s); + sink.add(LocationFixError(e.toString())); + }, + ), + ); + } + + @override + Future openLocationSettings() => _geolocator.openLocationSettings(); + + @override + Future openAppSettings() => _geolocator.openAppSettings(); + + /// Returns `null` when location is available, otherwise a failure + /// [LocationFix] describing why it is not. + Future _ensureAvailable() async { + final serviceEnabled = await _geolocator.isLocationServiceEnabled(); + if (!serviceEnabled) { + return const LocationServicesDisabled(); + } + + var permission = await _geolocator.checkPermission(); + if (permission == LocationPermission.denied) { + permission = await _geolocator.requestPermission(); + if (permission == LocationPermission.denied) { + return const LocationPermissionDenied(); + } + } + if (permission == LocationPermission.deniedForever) { + return const LocationPermissionDeniedForever(); + } + return null; + } + + GeoPosition _toGeoPosition(Position p) => GeoPosition( + latitude: p.latitude, + longitude: p.longitude, + accuracy: p.accuracy, + timestamp: p.timestamp, + ); +} diff --git a/lib/utils/services/location/model/geo_position.dart b/lib/utils/services/location/model/geo_position.dart new file mode 100644 index 00000000..645c8cc3 --- /dev/null +++ b/lib/utils/services/location/model/geo_position.dart @@ -0,0 +1,15 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'geo_position.freezed.dart'; + +/// Plugin-agnostic GPS position. Keeps `package:geolocator`'s `Position` +/// type from leaking past the location service boundary. +@freezed +abstract class GeoPosition with _$GeoPosition { + const factory GeoPosition({ + required double latitude, + required double longitude, + required double accuracy, + DateTime? timestamp, + }) = _GeoPosition; +} diff --git a/lib/utils/services/location/model/geo_position.freezed.dart b/lib/utils/services/location/model/geo_position.freezed.dart new file mode 100644 index 00000000..4a5aefc3 --- /dev/null +++ b/lib/utils/services/location/model/geo_position.freezed.dart @@ -0,0 +1,280 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'geo_position.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; +/// @nodoc +mixin _$GeoPosition { + + double get latitude; double get longitude; double get accuracy; DateTime? get timestamp; +/// Create a copy of GeoPosition +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$GeoPositionCopyWith get copyWith => _$GeoPositionCopyWithImpl(this as GeoPosition, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is GeoPosition&&(identical(other.latitude, latitude) || other.latitude == latitude)&&(identical(other.longitude, longitude) || other.longitude == longitude)&&(identical(other.accuracy, accuracy) || other.accuracy == accuracy)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)); +} + + +@override +int get hashCode => Object.hash(runtimeType,latitude,longitude,accuracy,timestamp); + +@override +String toString() { + return 'GeoPosition(latitude: $latitude, longitude: $longitude, accuracy: $accuracy, timestamp: $timestamp)'; +} + + +} + +/// @nodoc +abstract mixin class $GeoPositionCopyWith<$Res> { + factory $GeoPositionCopyWith(GeoPosition value, $Res Function(GeoPosition) _then) = _$GeoPositionCopyWithImpl; +@useResult +$Res call({ + double latitude, double longitude, double accuracy, DateTime? timestamp +}); + + + + +} +/// @nodoc +class _$GeoPositionCopyWithImpl<$Res> + implements $GeoPositionCopyWith<$Res> { + _$GeoPositionCopyWithImpl(this._self, this._then); + + final GeoPosition _self; + final $Res Function(GeoPosition) _then; + +/// Create a copy of GeoPosition +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? latitude = null,Object? longitude = null,Object? accuracy = null,Object? timestamp = freezed,}) { + return _then(_self.copyWith( +latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable +as double,longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable +as double,accuracy: null == accuracy ? _self.accuracy : accuracy // ignore: cast_nullable_to_non_nullable +as double,timestamp: freezed == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [GeoPosition]. +extension GeoPositionPatterns on GeoPosition { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _GeoPosition value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _GeoPosition() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _GeoPosition value) $default,){ +final _that = this; +switch (_that) { +case _GeoPosition(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _GeoPosition value)? $default,){ +final _that = this; +switch (_that) { +case _GeoPosition() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( double latitude, double longitude, double accuracy, DateTime? timestamp)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _GeoPosition() when $default != null: +return $default(_that.latitude,_that.longitude,_that.accuracy,_that.timestamp);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( double latitude, double longitude, double accuracy, DateTime? timestamp) $default,) {final _that = this; +switch (_that) { +case _GeoPosition(): +return $default(_that.latitude,_that.longitude,_that.accuracy,_that.timestamp);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( double latitude, double longitude, double accuracy, DateTime? timestamp)? $default,) {final _that = this; +switch (_that) { +case _GeoPosition() when $default != null: +return $default(_that.latitude,_that.longitude,_that.accuracy,_that.timestamp);case _: + return null; + +} +} + +} + +/// @nodoc + + +class _GeoPosition implements GeoPosition { + const _GeoPosition({required this.latitude, required this.longitude, required this.accuracy, this.timestamp}); + + +@override final double latitude; +@override final double longitude; +@override final double accuracy; +@override final DateTime? timestamp; + +/// Create a copy of GeoPosition +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$GeoPositionCopyWith<_GeoPosition> get copyWith => __$GeoPositionCopyWithImpl<_GeoPosition>(this, _$identity); + + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _GeoPosition&&(identical(other.latitude, latitude) || other.latitude == latitude)&&(identical(other.longitude, longitude) || other.longitude == longitude)&&(identical(other.accuracy, accuracy) || other.accuracy == accuracy)&&(identical(other.timestamp, timestamp) || other.timestamp == timestamp)); +} + + +@override +int get hashCode => Object.hash(runtimeType,latitude,longitude,accuracy,timestamp); + +@override +String toString() { + return 'GeoPosition(latitude: $latitude, longitude: $longitude, accuracy: $accuracy, timestamp: $timestamp)'; +} + + +} + +/// @nodoc +abstract mixin class _$GeoPositionCopyWith<$Res> implements $GeoPositionCopyWith<$Res> { + factory _$GeoPositionCopyWith(_GeoPosition value, $Res Function(_GeoPosition) _then) = __$GeoPositionCopyWithImpl; +@override @useResult +$Res call({ + double latitude, double longitude, double accuracy, DateTime? timestamp +}); + + + + +} +/// @nodoc +class __$GeoPositionCopyWithImpl<$Res> + implements _$GeoPositionCopyWith<$Res> { + __$GeoPositionCopyWithImpl(this._self, this._then); + + final _GeoPosition _self; + final $Res Function(_GeoPosition) _then; + +/// Create a copy of GeoPosition +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? latitude = null,Object? longitude = null,Object? accuracy = null,Object? timestamp = freezed,}) { + return _then(_GeoPosition( +latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable +as double,longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable +as double,accuracy: null == accuracy ? _self.accuracy : accuracy // ignore: cast_nullable_to_non_nullable +as double,timestamp: freezed == timestamp ? _self.timestamp : timestamp // ignore: cast_nullable_to_non_nullable +as DateTime?, + )); +} + + +} + +// dart format on diff --git a/lib/utils/services/location/model/location_fix.dart b/lib/utils/services/location/model/location_fix.dart new file mode 100644 index 00000000..84315e8b --- /dev/null +++ b/lib/utils/services/location/model/location_fix.dart @@ -0,0 +1,38 @@ +import 'package:thingsboard_app/utils/services/location/model/geo_position.dart'; + +/// Outcome of a location request. Exhaustively matched with `switch`, so every +/// caller is forced by the compiler to handle each failure mode. +sealed class LocationFix { + const LocationFix(); +} + +/// A position was obtained. +final class LocationSuccess extends LocationFix { + const LocationSuccess(this.position); + + final GeoPosition position; +} + +/// The OS location services are turned off. Caller may prompt the user and +/// call [ILocationService.openLocationSettings]. +final class LocationServicesDisabled extends LocationFix { + const LocationServicesDisabled(); +} + +/// Permission was denied but can be requested again later. +final class LocationPermissionDenied extends LocationFix { + const LocationPermissionDenied(); +} + +/// Permission was permanently denied. Caller must deep-link via +/// [ILocationService.openAppSettings]. +final class LocationPermissionDeniedForever extends LocationFix { + const LocationPermissionDeniedForever(); +} + +/// An unexpected platform error occurred. +final class LocationFixError extends LocationFix { + const LocationFixError(this.message); + + final String message; +} From e08b53aa590974143b5bf33f1cdb75ba32bc3f53 Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 10 Jun 2026 18:39:35 +0300 Subject: [PATCH 2/3] refactor(location): delegate GetLocationAction to LocationService via shared result mapper --- .../actions/get_location_action.dart | 61 ++++--------------- .../location_action_result_mapper.dart | 29 +++++++++ 2 files changed, 40 insertions(+), 50 deletions(-) create mode 100644 lib/utils/services/mobile_actions/actions/location_action_result_mapper.dart diff --git a/lib/utils/services/mobile_actions/actions/get_location_action.dart b/lib/utils/services/mobile_actions/actions/get_location_action.dart index 7a954226..4454fbde 100644 --- a/lib/utils/services/mobile_actions/actions/get_location_action.dart +++ b/lib/utils/services/mobile_actions/actions/get_location_action.dart @@ -1,64 +1,25 @@ import 'package:flutter_inappwebview/flutter_inappwebview.dart'; -import 'package:geolocator/geolocator.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/actions/location_action_result_mapper.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action.dart'; -import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action_result.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_result.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_type.dart'; -class GetLocationAction extends MobileAction { - Future _checkService() async { - final serviceEnabled = await Geolocator.isLocationServiceEnabled(); - - if (!serviceEnabled) { - if (!await Geolocator.openLocationSettings()) { - return false; - } - return _checkService(); - } - return true; - } - - Future _getLocation() async { +class GetLocationAction extends MobileAction with LocationActionResultMapper { + @override + Future execute( + List args, + InAppWebViewController controller, + ) async { try { - final serviceEnabled = await _checkService(); - if (!serviceEnabled) { - return WidgetMobileActionResult.errorResult( - 'Location services are disabled.', - ); - } - LocationPermission permission; - - permission = await Geolocator.checkPermission(); - if (permission == LocationPermission.denied) { - permission = await Geolocator.requestPermission(); - if (permission == LocationPermission.denied) { - return WidgetMobileActionResult.errorResult( - 'Location permissions are denied.', - ); - } - } - if (permission == LocationPermission.deniedForever) { - return WidgetMobileActionResult.errorResult( - 'Location permissions are permanently denied, we cannot request permissions.', - ); - } - final position = await Geolocator.getCurrentPosition( - desiredAccuracy: LocationAccuracy.high, - ); - return WidgetMobileActionResult.successResult( - MobileActionResult.location(position.latitude, position.longitude), - ); + final fix = await getIt().getCurrentPosition(); + return mapLocationFixToResult(fix); } catch (e) { return handleError(e); } } - @override - Future> execute( - List args, InAppWebViewController controller,) { - return _getLocation(); - } - @override WidgetMobileActionType get type => WidgetMobileActionType.getLocation; } diff --git a/lib/utils/services/mobile_actions/actions/location_action_result_mapper.dart b/lib/utils/services/mobile_actions/actions/location_action_result_mapper.dart new file mode 100644 index 00000000..88189668 --- /dev/null +++ b/lib/utils/services/mobile_actions/actions/location_action_result_mapper.dart @@ -0,0 +1,29 @@ +import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action_result.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_result.dart'; + +/// Shared mapping of a [LocationFix] to a [WidgetMobileActionResult], used by +/// both the one-shot `GetLocationAction` and the live `GetLiveLocationAction` +/// so the success/failure result contract stays identical between them. +mixin LocationActionResultMapper { + WidgetMobileActionResult mapLocationFixToResult(LocationFix fix) { + return switch (fix) { + LocationSuccess(:final position) => + WidgetMobileActionResult.successResult( + MobileActionResult.location(position.latitude, position.longitude), + ), + LocationServicesDisabled() => WidgetMobileActionResult.errorResult( + 'Location services are disabled.', + ), + LocationPermissionDenied() => WidgetMobileActionResult.errorResult( + 'Location permissions are denied.', + ), + LocationPermissionDeniedForever() => WidgetMobileActionResult.errorResult( + 'Location permissions are permanently denied, we cannot request permissions.', + ), + LocationFixError(:final message) => WidgetMobileActionResult.errorResult( + message, + ), + }; + } +} From 7e039d7827cad9483872607365244464d61c8c23 Mon Sep 17 00:00:00 2001 From: ababak Date: Wed, 10 Jun 2026 18:39:35 +0300 Subject: [PATCH 3/3] feat(location): add GetLiveLocationAction backed by positionStream --- .../actions/get_live_location_action.dart | 83 +++++++++++++++++++ .../mobile_actions/widget_action_handler.dart | 16 ++-- .../widget_mobile_action_type.dart | 1 + 3 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 lib/utils/services/mobile_actions/actions/get_live_location_action.dart diff --git a/lib/utils/services/mobile_actions/actions/get_live_location_action.dart b/lib/utils/services/mobile_actions/actions/get_live_location_action.dart new file mode 100644 index 00000000..7b1ea67e --- /dev/null +++ b/lib/utils/services/mobile_actions/actions/get_live_location_action.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:thingsboard_app/config/routes/v2/router_2.dart'; +import 'package:thingsboard_app/locator.dart'; +import 'package:thingsboard_app/utils/services/location/i_location_service.dart'; +import 'package:thingsboard_app/utils/services/location/model/location_fix.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/actions/location_action_result_mapper.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/mobile_action.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_result.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_type.dart'; + +/// Live counterpart of [WidgetMobileActionType.getLocation]: subscribes to +/// [ILocationService.positionStream] and reflects the device position as it +/// updates, rather than returning a single fix. +/// +/// NOTE: the dialog below is a placeholder visualization used to exercise the +/// live stream — the production UI is not decided yet. The action is registered +/// under [WidgetMobileActionType.getLiveLocation] but is not yet triggered by +/// any dashboard widget. +class GetLiveLocationAction extends MobileAction + with LocationActionResultMapper { + @override + Future execute( + List args, + InAppWebViewController controller, + ) async { + try { + final service = getIt(); + final context = globalNavigatorKey.currentContext!; + + // Most recent successful fix, handed back when the dialog is dismissed. + LocationFix? lastFix; + + await showDialog( + context: context, + builder: (dialogContext) { + return AlertDialog( + title: const Text('Live location'), + content: StreamBuilder( + stream: service.positionStream(), + builder: (ctx, snapshot) { + final fix = snapshot.data; + if (fix is LocationSuccess) { + lastFix = fix; + } + return Text(_describe(fix)); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Close'), + ), + ], + ); + }, + ); + + return lastFix == null + ? WidgetMobileActionResult.emptyResult() + : mapLocationFixToResult(lastFix!); + } catch (e) { + return handleError(e); + } + } + + String _describe(LocationFix? fix) => switch (fix) { + null => 'Waiting for first GPS fix…', + LocationSuccess(:final position) => + 'lat: ${position.latitude.toStringAsFixed(6)}\n' + 'lng: ${position.longitude.toStringAsFixed(6)}\n' + 'accuracy: ${position.accuracy.toStringAsFixed(1)} m\n' + 'updated: ${position.timestamp}', + LocationServicesDisabled() => 'Location services are disabled.', + LocationPermissionDenied() => 'Location permission denied.', + LocationPermissionDeniedForever() => + 'Location permission permanently denied.', + LocationFixError(:final message) => 'Error: $message', + }; + + @override + WidgetMobileActionType get type => WidgetMobileActionType.getLiveLocation; +} diff --git a/lib/utils/services/mobile_actions/widget_action_handler.dart b/lib/utils/services/mobile_actions/widget_action_handler.dart index efb881b2..4d49afbc 100644 --- a/lib/utils/services/mobile_actions/widget_action_handler.dart +++ b/lib/utils/services/mobile_actions/widget_action_handler.dart @@ -1,6 +1,6 @@ - import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/device_provisioning_action.dart'; +import 'package:thingsboard_app/utils/services/mobile_actions/actions/get_live_location_action.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/get_location_action.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/make_phone_call_action.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/actions/scan_qr_action.dart'; @@ -12,8 +12,9 @@ import 'package:thingsboard_app/utils/services/mobile_actions/actions/take_scree import 'package:thingsboard_app/utils/services/mobile_actions/actions/unknown_action.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_result.dart'; import 'package:thingsboard_app/utils/services/mobile_actions/widget_mobile_action_type.dart'; + class WidgetActionHandler { - static final actions = [ + static final actions = [ DeviceProvisioningAction(), UnknownAction(), ShowMapLocationAction(), @@ -23,6 +24,7 @@ class WidgetActionHandler { ScanQrAction(), MakePhoneCallAction(), GetLocationAction(), + GetLiveLocationAction(), TakeScreenshotAction(), ]; Future> handleWidgetMobileAction( @@ -43,19 +45,11 @@ class WidgetActionHandler { (action) => action.type == actionType, orElse: () => UnknownAction(), ); - return await actionToCall.execute( - args, - controller, - ); + return await actionToCall.execute(args, controller); } else { return WidgetMobileActionResult.errorResult( 'actionType is not provided.', ); } } - - - - - } diff --git a/lib/utils/services/mobile_actions/widget_mobile_action_type.dart b/lib/utils/services/mobile_actions/widget_mobile_action_type.dart index cced4aab..420d0892 100644 --- a/lib/utils/services/mobile_actions/widget_mobile_action_type.dart +++ b/lib/utils/services/mobile_actions/widget_mobile_action_type.dart @@ -6,6 +6,7 @@ enum WidgetMobileActionType { scanQrCode, makePhoneCall, getLocation, + getLiveLocation, takeScreenshot, deviceProvision, unknown;