feat(location): add history playback, GPS functions dialog and UX improvements

This commit is contained in:
2026-04-26 08:25:05 +02:00
parent e901b22981
commit 04b8d1609c
14 changed files with 1248 additions and 172 deletions

View File

@@ -34,7 +34,7 @@ final class LocationControllerProvider
}
String _$locationControllerHash() =>
r'43534cd92b74ec5fabc9a43c6ef8398846855cf4';
r'324461865e3f1deb94ef6bba29cb3a200e5fe14f';
abstract class _$LocationController extends $AsyncNotifier<LocationState> {
FutureOr<LocationState> build();

View File

@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:ui';
import 'package:latlong2/latlong.dart';
import 'package:legacy_device_state/legacy_device_state.dart';
@@ -15,12 +16,22 @@ class LocationMapController extends _$LocationMapController {
late final SfTrackingRepository _tracking;
Timer? _zoomDebounce;
Timer? _playbackTimer;
Timer? _recenterTimer;
static const Duration _zoomDebounceDelay = Duration(seconds: 1);
static const int _playbackSeconds = 3;
static const int _recenterSeconds = 3;
VoidCallback? onPlaybackTick;
@override
LocationMapState build() {
_tracking = ref.read(sfTrackingProvider);
ref.onDispose(() => _zoomDebounce?.cancel());
ref.onDispose(() {
_zoomDebounce?.cancel();
_playbackTimer?.cancel();
_recenterTimer?.cancel();
});
return const LocationMapState();
}
@@ -56,6 +67,8 @@ class LocationMapController extends _$LocationMapController {
placingMode: PlacingMode.none,
adjustingRadius: false,
previewPoint: null,
editingGeofence: null,
editingFrequentPlace: null,
);
}
@@ -75,13 +88,17 @@ class LocationMapController extends _$LocationMapController {
placingMode: PlacingMode.none,
adjustingRadius: true,
previewPoint: center,
previewRadius: 200,
previewRadius: state.editingGeofence?.radius ?? 200,
);
}
void confirmFrequentPlacePlacement() {
unawaited(_tracking.legacyLocationPointConfirmed('frequent_place'));
state = state.copyWith(placingMode: PlacingMode.none, previewPoint: null);
state = state.copyWith(
placingMode: PlacingMode.none,
previewPoint: null,
editingFrequentPlace: null,
);
}
void updatePreviewRadius(double radius) {
@@ -123,9 +140,19 @@ class LocationMapController extends _$LocationMapController {
state = state.copyWith(
selectedGeofence: null,
editingGeofence: geofence,
placingMode: PlacingMode.geofence,
previewPoint: LatLng(geofence.latitude, geofence.longitude),
previewRadius: geofence.radius,
adjustingRadius: true,
);
}
void startEditingFrequentPlace(FrequentPlaceEntity place) {
unawaited(_tracking.legacyLocationFrequentPlaceSelected());
state = state.copyWith(
selectedFrequentPlace: null,
editingFrequentPlace: place,
placingMode: PlacingMode.frequentPlace,
previewPoint: LatLng(place.lat, place.lng),
);
}
@@ -191,6 +218,96 @@ class LocationMapController extends _$LocationMapController {
});
}
void startReveal() {
state = state.copyWith(isRevealAnimating: true, historyRevealCount: 1);
}
void updateRevealCount(int count) {
state = state.copyWith(historyRevealCount: count);
}
void finishReveal() {
state = state.copyWith(isRevealAnimating: false, historyRevealCount: null);
}
void startHistoryNavigation() {
state = state.copyWith(historyNavigationIndex: 0, historyPlaying: false);
}
void stopHistoryNavigation() {
state = state.copyWith(historyNavigationIndex: -1, historyPlaying: false);
}
void setHistoryIndex(int index) {
state = state.copyWith(historyNavigationIndex: index);
}
void nextHistoryPosition(int total) {
if (state.historyNavigationIndex < total - 1) {
state = state.copyWith(
historyNavigationIndex: state.historyNavigationIndex + 1,
);
} else {
state = state.copyWith(historyPlaying: false);
}
}
void previousHistoryPosition() {
if (state.historyNavigationIndex > 0) {
state = state.copyWith(
historyNavigationIndex: state.historyNavigationIndex - 1,
);
}
}
void toggleHistoryPlayback(int total) {
if (state.historyPlaying) {
stopPlaybackTimer();
state = state.copyWith(historyPlaying: false);
} else {
final atEnd = state.historyNavigationIndex >= total - 1;
state = state.copyWith(
historyNavigationIndex: atEnd ? 0 : state.historyNavigationIndex,
historyPlaying: true,
);
startPlaybackTimer(total);
}
}
void startPlaybackTimer(int total) {
_playbackTimer?.cancel();
_playbackTimer = Timer.periodic(const Duration(seconds: _playbackSeconds), (
_,
) {
if (!state.historyPlaying) {
_playbackTimer?.cancel();
return;
}
nextHistoryPosition(total);
onPlaybackTick?.call();
if (!state.historyPlaying) {
_playbackTimer?.cancel();
}
});
}
void stopPlaybackTimer() {
_playbackTimer?.cancel();
_playbackTimer = null;
}
void scheduleRecenter(VoidCallback onRecenter) {
if (!state.isFollowing) return;
_recenterTimer?.cancel();
_recenterTimer = Timer(const Duration(seconds: _recenterSeconds), () {
if (state.isFollowing) onRecenter();
});
}
void cancelRecenter() {
_recenterTimer?.cancel();
}
String _modeName(PlacingMode mode) {
switch (mode) {
case PlacingMode.geofence:

View File

@@ -42,7 +42,7 @@ final class LocationMapControllerProvider
}
String _$locationMapControllerHash() =>
r'c6eea4cec7a9a66546e9b66baf384edbb6e320f2';
r'565ab5147dd5ac16cf80210c55e28316bafe2f5a';
abstract class _$LocationMapController extends $Notifier<LocationMapState> {
LocationMapState build();

View File

@@ -22,10 +22,15 @@ abstract class LocationMapState with _$LocationMapState {
GeofenceEntity? selectedGeofence,
GeofenceEntity? editingGeofence,
FrequentPlaceEntity? selectedFrequentPlace,
FrequentPlaceEntity? editingFrequentPlace,
PositionEntity? selectedHistoryPosition,
@Default(false) bool isFollowing,
@Default(false) bool actionsExpanded,
@Default(false) bool frequencyExpanded,
@Default(_defaultZoom) double mapZoom,
@Default(-1) int historyNavigationIndex,
@Default(false) bool historyPlaying,
@Default(false) bool isRevealAnimating,
int? historyRevealCount,
}) = _LocationMapState;
}

View File

@@ -14,7 +14,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$LocationMapState {
bool get showGeofences; bool get showFrequentPlaces; PlacingMode get placingMode; bool get adjustingRadius; double get previewRadius; LatLng? get previewPoint; GeofenceEntity? get selectedGeofence; GeofenceEntity? get editingGeofence; FrequentPlaceEntity? get selectedFrequentPlace; PositionEntity? get selectedHistoryPosition; bool get isFollowing; bool get actionsExpanded; bool get frequencyExpanded; double get mapZoom;
bool get showGeofences; bool get showFrequentPlaces; PlacingMode get placingMode; bool get adjustingRadius; double get previewRadius; LatLng? get previewPoint; GeofenceEntity? get selectedGeofence; GeofenceEntity? get editingGeofence; FrequentPlaceEntity? get selectedFrequentPlace; FrequentPlaceEntity? get editingFrequentPlace; PositionEntity? get selectedHistoryPosition; bool get isFollowing; bool get actionsExpanded; bool get frequencyExpanded; double get mapZoom; int get historyNavigationIndex; bool get historyPlaying; bool get isRevealAnimating; int? get historyRevealCount;
/// Create a copy of LocationMapState
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -25,16 +25,16 @@ $LocationMapStateCopyWith<LocationMapState> get copyWith => _$LocationMapStateCo
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is LocationMapState&&(identical(other.showGeofences, showGeofences) || other.showGeofences == showGeofences)&&(identical(other.showFrequentPlaces, showFrequentPlaces) || other.showFrequentPlaces == showFrequentPlaces)&&(identical(other.placingMode, placingMode) || other.placingMode == placingMode)&&(identical(other.adjustingRadius, adjustingRadius) || other.adjustingRadius == adjustingRadius)&&(identical(other.previewRadius, previewRadius) || other.previewRadius == previewRadius)&&(identical(other.previewPoint, previewPoint) || other.previewPoint == previewPoint)&&(identical(other.selectedGeofence, selectedGeofence) || other.selectedGeofence == selectedGeofence)&&(identical(other.editingGeofence, editingGeofence) || other.editingGeofence == editingGeofence)&&(identical(other.selectedFrequentPlace, selectedFrequentPlace) || other.selectedFrequentPlace == selectedFrequentPlace)&&(identical(other.selectedHistoryPosition, selectedHistoryPosition) || other.selectedHistoryPosition == selectedHistoryPosition)&&(identical(other.isFollowing, isFollowing) || other.isFollowing == isFollowing)&&(identical(other.actionsExpanded, actionsExpanded) || other.actionsExpanded == actionsExpanded)&&(identical(other.frequencyExpanded, frequencyExpanded) || other.frequencyExpanded == frequencyExpanded)&&(identical(other.mapZoom, mapZoom) || other.mapZoom == mapZoom));
return identical(this, other) || (other.runtimeType == runtimeType&&other is LocationMapState&&(identical(other.showGeofences, showGeofences) || other.showGeofences == showGeofences)&&(identical(other.showFrequentPlaces, showFrequentPlaces) || other.showFrequentPlaces == showFrequentPlaces)&&(identical(other.placingMode, placingMode) || other.placingMode == placingMode)&&(identical(other.adjustingRadius, adjustingRadius) || other.adjustingRadius == adjustingRadius)&&(identical(other.previewRadius, previewRadius) || other.previewRadius == previewRadius)&&(identical(other.previewPoint, previewPoint) || other.previewPoint == previewPoint)&&(identical(other.selectedGeofence, selectedGeofence) || other.selectedGeofence == selectedGeofence)&&(identical(other.editingGeofence, editingGeofence) || other.editingGeofence == editingGeofence)&&(identical(other.selectedFrequentPlace, selectedFrequentPlace) || other.selectedFrequentPlace == selectedFrequentPlace)&&(identical(other.editingFrequentPlace, editingFrequentPlace) || other.editingFrequentPlace == editingFrequentPlace)&&(identical(other.selectedHistoryPosition, selectedHistoryPosition) || other.selectedHistoryPosition == selectedHistoryPosition)&&(identical(other.isFollowing, isFollowing) || other.isFollowing == isFollowing)&&(identical(other.actionsExpanded, actionsExpanded) || other.actionsExpanded == actionsExpanded)&&(identical(other.frequencyExpanded, frequencyExpanded) || other.frequencyExpanded == frequencyExpanded)&&(identical(other.mapZoom, mapZoom) || other.mapZoom == mapZoom)&&(identical(other.historyNavigationIndex, historyNavigationIndex) || other.historyNavigationIndex == historyNavigationIndex)&&(identical(other.historyPlaying, historyPlaying) || other.historyPlaying == historyPlaying)&&(identical(other.isRevealAnimating, isRevealAnimating) || other.isRevealAnimating == isRevealAnimating)&&(identical(other.historyRevealCount, historyRevealCount) || other.historyRevealCount == historyRevealCount));
}
@override
int get hashCode => Object.hash(runtimeType,showGeofences,showFrequentPlaces,placingMode,adjustingRadius,previewRadius,previewPoint,selectedGeofence,editingGeofence,selectedFrequentPlace,selectedHistoryPosition,isFollowing,actionsExpanded,frequencyExpanded,mapZoom);
int get hashCode => Object.hashAll([runtimeType,showGeofences,showFrequentPlaces,placingMode,adjustingRadius,previewRadius,previewPoint,selectedGeofence,editingGeofence,selectedFrequentPlace,editingFrequentPlace,selectedHistoryPosition,isFollowing,actionsExpanded,frequencyExpanded,mapZoom,historyNavigationIndex,historyPlaying,isRevealAnimating,historyRevealCount]);
@override
String toString() {
return 'LocationMapState(showGeofences: $showGeofences, showFrequentPlaces: $showFrequentPlaces, placingMode: $placingMode, adjustingRadius: $adjustingRadius, previewRadius: $previewRadius, previewPoint: $previewPoint, selectedGeofence: $selectedGeofence, editingGeofence: $editingGeofence, selectedFrequentPlace: $selectedFrequentPlace, selectedHistoryPosition: $selectedHistoryPosition, isFollowing: $isFollowing, actionsExpanded: $actionsExpanded, frequencyExpanded: $frequencyExpanded, mapZoom: $mapZoom)';
return 'LocationMapState(showGeofences: $showGeofences, showFrequentPlaces: $showFrequentPlaces, placingMode: $placingMode, adjustingRadius: $adjustingRadius, previewRadius: $previewRadius, previewPoint: $previewPoint, selectedGeofence: $selectedGeofence, editingGeofence: $editingGeofence, selectedFrequentPlace: $selectedFrequentPlace, editingFrequentPlace: $editingFrequentPlace, selectedHistoryPosition: $selectedHistoryPosition, isFollowing: $isFollowing, actionsExpanded: $actionsExpanded, frequencyExpanded: $frequencyExpanded, mapZoom: $mapZoom, historyNavigationIndex: $historyNavigationIndex, historyPlaying: $historyPlaying, isRevealAnimating: $isRevealAnimating, historyRevealCount: $historyRevealCount)';
}
@@ -45,11 +45,11 @@ abstract mixin class $LocationMapStateCopyWith<$Res> {
factory $LocationMapStateCopyWith(LocationMapState value, $Res Function(LocationMapState) _then) = _$LocationMapStateCopyWithImpl;
@useResult
$Res call({
bool showGeofences, bool showFrequentPlaces, PlacingMode placingMode, bool adjustingRadius, double previewRadius, LatLng? previewPoint, GeofenceEntity? selectedGeofence, GeofenceEntity? editingGeofence, FrequentPlaceEntity? selectedFrequentPlace, PositionEntity? selectedHistoryPosition, bool isFollowing, bool actionsExpanded, bool frequencyExpanded, double mapZoom
bool showGeofences, bool showFrequentPlaces, PlacingMode placingMode, bool adjustingRadius, double previewRadius, LatLng? previewPoint, GeofenceEntity? selectedGeofence, GeofenceEntity? editingGeofence, FrequentPlaceEntity? selectedFrequentPlace, FrequentPlaceEntity? editingFrequentPlace, PositionEntity? selectedHistoryPosition, bool isFollowing, bool actionsExpanded, bool frequencyExpanded, double mapZoom, int historyNavigationIndex, bool historyPlaying, bool isRevealAnimating, int? historyRevealCount
});
$GeofenceEntityCopyWith<$Res>? get selectedGeofence;$GeofenceEntityCopyWith<$Res>? get editingGeofence;$FrequentPlaceEntityCopyWith<$Res>? get selectedFrequentPlace;$PositionEntityCopyWith<$Res>? get selectedHistoryPosition;
$GeofenceEntityCopyWith<$Res>? get selectedGeofence;$GeofenceEntityCopyWith<$Res>? get editingGeofence;$FrequentPlaceEntityCopyWith<$Res>? get selectedFrequentPlace;$FrequentPlaceEntityCopyWith<$Res>? get editingFrequentPlace;$PositionEntityCopyWith<$Res>? get selectedHistoryPosition;
}
/// @nodoc
@@ -62,7 +62,7 @@ class _$LocationMapStateCopyWithImpl<$Res>
/// Create a copy of LocationMapState
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? showGeofences = null,Object? showFrequentPlaces = null,Object? placingMode = null,Object? adjustingRadius = null,Object? previewRadius = null,Object? previewPoint = freezed,Object? selectedGeofence = freezed,Object? editingGeofence = freezed,Object? selectedFrequentPlace = freezed,Object? selectedHistoryPosition = freezed,Object? isFollowing = null,Object? actionsExpanded = null,Object? frequencyExpanded = null,Object? mapZoom = null,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? showGeofences = null,Object? showFrequentPlaces = null,Object? placingMode = null,Object? adjustingRadius = null,Object? previewRadius = null,Object? previewPoint = freezed,Object? selectedGeofence = freezed,Object? editingGeofence = freezed,Object? selectedFrequentPlace = freezed,Object? editingFrequentPlace = freezed,Object? selectedHistoryPosition = freezed,Object? isFollowing = null,Object? actionsExpanded = null,Object? frequencyExpanded = null,Object? mapZoom = null,Object? historyNavigationIndex = null,Object? historyPlaying = null,Object? isRevealAnimating = null,Object? historyRevealCount = freezed,}) {
return _then(_self.copyWith(
showGeofences: null == showGeofences ? _self.showGeofences : showGeofences // ignore: cast_nullable_to_non_nullable
as bool,showFrequentPlaces: null == showFrequentPlaces ? _self.showFrequentPlaces : showFrequentPlaces // ignore: cast_nullable_to_non_nullable
@@ -73,12 +73,17 @@ as double,previewPoint: freezed == previewPoint ? _self.previewPoint : previewPo
as LatLng?,selectedGeofence: freezed == selectedGeofence ? _self.selectedGeofence : selectedGeofence // ignore: cast_nullable_to_non_nullable
as GeofenceEntity?,editingGeofence: freezed == editingGeofence ? _self.editingGeofence : editingGeofence // ignore: cast_nullable_to_non_nullable
as GeofenceEntity?,selectedFrequentPlace: freezed == selectedFrequentPlace ? _self.selectedFrequentPlace : selectedFrequentPlace // ignore: cast_nullable_to_non_nullable
as FrequentPlaceEntity?,editingFrequentPlace: freezed == editingFrequentPlace ? _self.editingFrequentPlace : editingFrequentPlace // ignore: cast_nullable_to_non_nullable
as FrequentPlaceEntity?,selectedHistoryPosition: freezed == selectedHistoryPosition ? _self.selectedHistoryPosition : selectedHistoryPosition // ignore: cast_nullable_to_non_nullable
as PositionEntity?,isFollowing: null == isFollowing ? _self.isFollowing : isFollowing // ignore: cast_nullable_to_non_nullable
as bool,actionsExpanded: null == actionsExpanded ? _self.actionsExpanded : actionsExpanded // ignore: cast_nullable_to_non_nullable
as bool,frequencyExpanded: null == frequencyExpanded ? _self.frequencyExpanded : frequencyExpanded // ignore: cast_nullable_to_non_nullable
as bool,mapZoom: null == mapZoom ? _self.mapZoom : mapZoom // ignore: cast_nullable_to_non_nullable
as double,
as double,historyNavigationIndex: null == historyNavigationIndex ? _self.historyNavigationIndex : historyNavigationIndex // ignore: cast_nullable_to_non_nullable
as int,historyPlaying: null == historyPlaying ? _self.historyPlaying : historyPlaying // ignore: cast_nullable_to_non_nullable
as bool,isRevealAnimating: null == isRevealAnimating ? _self.isRevealAnimating : isRevealAnimating // ignore: cast_nullable_to_non_nullable
as bool,historyRevealCount: freezed == historyRevealCount ? _self.historyRevealCount : historyRevealCount // ignore: cast_nullable_to_non_nullable
as int?,
));
}
/// Create a copy of LocationMapState
@@ -121,6 +126,18 @@ $FrequentPlaceEntityCopyWith<$Res>? get selectedFrequentPlace {
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$FrequentPlaceEntityCopyWith<$Res>? get editingFrequentPlace {
if (_self.editingFrequentPlace == null) {
return null;
}
return $FrequentPlaceEntityCopyWith<$Res>(_self.editingFrequentPlace!, (value) {
return _then(_self.copyWith(editingFrequentPlace: value));
});
}/// Create a copy of LocationMapState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$PositionEntityCopyWith<$Res>? get selectedHistoryPosition {
if (_self.selectedHistoryPosition == null) {
return null;
@@ -211,10 +228,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool showGeofences, bool showFrequentPlaces, PlacingMode placingMode, bool adjustingRadius, double previewRadius, LatLng? previewPoint, GeofenceEntity? selectedGeofence, GeofenceEntity? editingGeofence, FrequentPlaceEntity? selectedFrequentPlace, PositionEntity? selectedHistoryPosition, bool isFollowing, bool actionsExpanded, bool frequencyExpanded, double mapZoom)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( bool showGeofences, bool showFrequentPlaces, PlacingMode placingMode, bool adjustingRadius, double previewRadius, LatLng? previewPoint, GeofenceEntity? selectedGeofence, GeofenceEntity? editingGeofence, FrequentPlaceEntity? selectedFrequentPlace, FrequentPlaceEntity? editingFrequentPlace, PositionEntity? selectedHistoryPosition, bool isFollowing, bool actionsExpanded, bool frequencyExpanded, double mapZoom, int historyNavigationIndex, bool historyPlaying, bool isRevealAnimating, int? historyRevealCount)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _LocationMapState() when $default != null:
return $default(_that.showGeofences,_that.showFrequentPlaces,_that.placingMode,_that.adjustingRadius,_that.previewRadius,_that.previewPoint,_that.selectedGeofence,_that.editingGeofence,_that.selectedFrequentPlace,_that.selectedHistoryPosition,_that.isFollowing,_that.actionsExpanded,_that.frequencyExpanded,_that.mapZoom);case _:
return $default(_that.showGeofences,_that.showFrequentPlaces,_that.placingMode,_that.adjustingRadius,_that.previewRadius,_that.previewPoint,_that.selectedGeofence,_that.editingGeofence,_that.selectedFrequentPlace,_that.editingFrequentPlace,_that.selectedHistoryPosition,_that.isFollowing,_that.actionsExpanded,_that.frequencyExpanded,_that.mapZoom,_that.historyNavigationIndex,_that.historyPlaying,_that.isRevealAnimating,_that.historyRevealCount);case _:
return orElse();
}
@@ -232,10 +249,10 @@ return $default(_that.showGeofences,_that.showFrequentPlaces,_that.placingMode,_
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool showGeofences, bool showFrequentPlaces, PlacingMode placingMode, bool adjustingRadius, double previewRadius, LatLng? previewPoint, GeofenceEntity? selectedGeofence, GeofenceEntity? editingGeofence, FrequentPlaceEntity? selectedFrequentPlace, PositionEntity? selectedHistoryPosition, bool isFollowing, bool actionsExpanded, bool frequencyExpanded, double mapZoom) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( bool showGeofences, bool showFrequentPlaces, PlacingMode placingMode, bool adjustingRadius, double previewRadius, LatLng? previewPoint, GeofenceEntity? selectedGeofence, GeofenceEntity? editingGeofence, FrequentPlaceEntity? selectedFrequentPlace, FrequentPlaceEntity? editingFrequentPlace, PositionEntity? selectedHistoryPosition, bool isFollowing, bool actionsExpanded, bool frequencyExpanded, double mapZoom, int historyNavigationIndex, bool historyPlaying, bool isRevealAnimating, int? historyRevealCount) $default,) {final _that = this;
switch (_that) {
case _LocationMapState():
return $default(_that.showGeofences,_that.showFrequentPlaces,_that.placingMode,_that.adjustingRadius,_that.previewRadius,_that.previewPoint,_that.selectedGeofence,_that.editingGeofence,_that.selectedFrequentPlace,_that.selectedHistoryPosition,_that.isFollowing,_that.actionsExpanded,_that.frequencyExpanded,_that.mapZoom);case _:
return $default(_that.showGeofences,_that.showFrequentPlaces,_that.placingMode,_that.adjustingRadius,_that.previewRadius,_that.previewPoint,_that.selectedGeofence,_that.editingGeofence,_that.selectedFrequentPlace,_that.editingFrequentPlace,_that.selectedHistoryPosition,_that.isFollowing,_that.actionsExpanded,_that.frequencyExpanded,_that.mapZoom,_that.historyNavigationIndex,_that.historyPlaying,_that.isRevealAnimating,_that.historyRevealCount);case _:
throw StateError('Unexpected subclass');
}
@@ -252,10 +269,10 @@ return $default(_that.showGeofences,_that.showFrequentPlaces,_that.placingMode,_
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool showGeofences, bool showFrequentPlaces, PlacingMode placingMode, bool adjustingRadius, double previewRadius, LatLng? previewPoint, GeofenceEntity? selectedGeofence, GeofenceEntity? editingGeofence, FrequentPlaceEntity? selectedFrequentPlace, PositionEntity? selectedHistoryPosition, bool isFollowing, bool actionsExpanded, bool frequencyExpanded, double mapZoom)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( bool showGeofences, bool showFrequentPlaces, PlacingMode placingMode, bool adjustingRadius, double previewRadius, LatLng? previewPoint, GeofenceEntity? selectedGeofence, GeofenceEntity? editingGeofence, FrequentPlaceEntity? selectedFrequentPlace, FrequentPlaceEntity? editingFrequentPlace, PositionEntity? selectedHistoryPosition, bool isFollowing, bool actionsExpanded, bool frequencyExpanded, double mapZoom, int historyNavigationIndex, bool historyPlaying, bool isRevealAnimating, int? historyRevealCount)? $default,) {final _that = this;
switch (_that) {
case _LocationMapState() when $default != null:
return $default(_that.showGeofences,_that.showFrequentPlaces,_that.placingMode,_that.adjustingRadius,_that.previewRadius,_that.previewPoint,_that.selectedGeofence,_that.editingGeofence,_that.selectedFrequentPlace,_that.selectedHistoryPosition,_that.isFollowing,_that.actionsExpanded,_that.frequencyExpanded,_that.mapZoom);case _:
return $default(_that.showGeofences,_that.showFrequentPlaces,_that.placingMode,_that.adjustingRadius,_that.previewRadius,_that.previewPoint,_that.selectedGeofence,_that.editingGeofence,_that.selectedFrequentPlace,_that.editingFrequentPlace,_that.selectedHistoryPosition,_that.isFollowing,_that.actionsExpanded,_that.frequencyExpanded,_that.mapZoom,_that.historyNavigationIndex,_that.historyPlaying,_that.isRevealAnimating,_that.historyRevealCount);case _:
return null;
}
@@ -267,7 +284,7 @@ return $default(_that.showGeofences,_that.showFrequentPlaces,_that.placingMode,_
class _LocationMapState implements LocationMapState {
const _LocationMapState({this.showGeofences = true, this.showFrequentPlaces = true, this.placingMode = PlacingMode.none, this.adjustingRadius = false, this.previewRadius = 200.0, this.previewPoint, this.selectedGeofence, this.editingGeofence, this.selectedFrequentPlace, this.selectedHistoryPosition, this.isFollowing = false, this.actionsExpanded = false, this.frequencyExpanded = false, this.mapZoom = _defaultZoom});
const _LocationMapState({this.showGeofences = true, this.showFrequentPlaces = true, this.placingMode = PlacingMode.none, this.adjustingRadius = false, this.previewRadius = 200.0, this.previewPoint, this.selectedGeofence, this.editingGeofence, this.selectedFrequentPlace, this.editingFrequentPlace, this.selectedHistoryPosition, this.isFollowing = false, this.actionsExpanded = false, this.frequencyExpanded = false, this.mapZoom = _defaultZoom, this.historyNavigationIndex = -1, this.historyPlaying = false, this.isRevealAnimating = false, this.historyRevealCount});
@override@JsonKey() final bool showGeofences;
@@ -279,11 +296,16 @@ class _LocationMapState implements LocationMapState {
@override final GeofenceEntity? selectedGeofence;
@override final GeofenceEntity? editingGeofence;
@override final FrequentPlaceEntity? selectedFrequentPlace;
@override final FrequentPlaceEntity? editingFrequentPlace;
@override final PositionEntity? selectedHistoryPosition;
@override@JsonKey() final bool isFollowing;
@override@JsonKey() final bool actionsExpanded;
@override@JsonKey() final bool frequencyExpanded;
@override@JsonKey() final double mapZoom;
@override@JsonKey() final int historyNavigationIndex;
@override@JsonKey() final bool historyPlaying;
@override@JsonKey() final bool isRevealAnimating;
@override final int? historyRevealCount;
/// Create a copy of LocationMapState
/// with the given fields replaced by the non-null parameter values.
@@ -295,16 +317,16 @@ _$LocationMapStateCopyWith<_LocationMapState> get copyWith => __$LocationMapStat
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _LocationMapState&&(identical(other.showGeofences, showGeofences) || other.showGeofences == showGeofences)&&(identical(other.showFrequentPlaces, showFrequentPlaces) || other.showFrequentPlaces == showFrequentPlaces)&&(identical(other.placingMode, placingMode) || other.placingMode == placingMode)&&(identical(other.adjustingRadius, adjustingRadius) || other.adjustingRadius == adjustingRadius)&&(identical(other.previewRadius, previewRadius) || other.previewRadius == previewRadius)&&(identical(other.previewPoint, previewPoint) || other.previewPoint == previewPoint)&&(identical(other.selectedGeofence, selectedGeofence) || other.selectedGeofence == selectedGeofence)&&(identical(other.editingGeofence, editingGeofence) || other.editingGeofence == editingGeofence)&&(identical(other.selectedFrequentPlace, selectedFrequentPlace) || other.selectedFrequentPlace == selectedFrequentPlace)&&(identical(other.selectedHistoryPosition, selectedHistoryPosition) || other.selectedHistoryPosition == selectedHistoryPosition)&&(identical(other.isFollowing, isFollowing) || other.isFollowing == isFollowing)&&(identical(other.actionsExpanded, actionsExpanded) || other.actionsExpanded == actionsExpanded)&&(identical(other.frequencyExpanded, frequencyExpanded) || other.frequencyExpanded == frequencyExpanded)&&(identical(other.mapZoom, mapZoom) || other.mapZoom == mapZoom));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _LocationMapState&&(identical(other.showGeofences, showGeofences) || other.showGeofences == showGeofences)&&(identical(other.showFrequentPlaces, showFrequentPlaces) || other.showFrequentPlaces == showFrequentPlaces)&&(identical(other.placingMode, placingMode) || other.placingMode == placingMode)&&(identical(other.adjustingRadius, adjustingRadius) || other.adjustingRadius == adjustingRadius)&&(identical(other.previewRadius, previewRadius) || other.previewRadius == previewRadius)&&(identical(other.previewPoint, previewPoint) || other.previewPoint == previewPoint)&&(identical(other.selectedGeofence, selectedGeofence) || other.selectedGeofence == selectedGeofence)&&(identical(other.editingGeofence, editingGeofence) || other.editingGeofence == editingGeofence)&&(identical(other.selectedFrequentPlace, selectedFrequentPlace) || other.selectedFrequentPlace == selectedFrequentPlace)&&(identical(other.editingFrequentPlace, editingFrequentPlace) || other.editingFrequentPlace == editingFrequentPlace)&&(identical(other.selectedHistoryPosition, selectedHistoryPosition) || other.selectedHistoryPosition == selectedHistoryPosition)&&(identical(other.isFollowing, isFollowing) || other.isFollowing == isFollowing)&&(identical(other.actionsExpanded, actionsExpanded) || other.actionsExpanded == actionsExpanded)&&(identical(other.frequencyExpanded, frequencyExpanded) || other.frequencyExpanded == frequencyExpanded)&&(identical(other.mapZoom, mapZoom) || other.mapZoom == mapZoom)&&(identical(other.historyNavigationIndex, historyNavigationIndex) || other.historyNavigationIndex == historyNavigationIndex)&&(identical(other.historyPlaying, historyPlaying) || other.historyPlaying == historyPlaying)&&(identical(other.isRevealAnimating, isRevealAnimating) || other.isRevealAnimating == isRevealAnimating)&&(identical(other.historyRevealCount, historyRevealCount) || other.historyRevealCount == historyRevealCount));
}
@override
int get hashCode => Object.hash(runtimeType,showGeofences,showFrequentPlaces,placingMode,adjustingRadius,previewRadius,previewPoint,selectedGeofence,editingGeofence,selectedFrequentPlace,selectedHistoryPosition,isFollowing,actionsExpanded,frequencyExpanded,mapZoom);
int get hashCode => Object.hashAll([runtimeType,showGeofences,showFrequentPlaces,placingMode,adjustingRadius,previewRadius,previewPoint,selectedGeofence,editingGeofence,selectedFrequentPlace,editingFrequentPlace,selectedHistoryPosition,isFollowing,actionsExpanded,frequencyExpanded,mapZoom,historyNavigationIndex,historyPlaying,isRevealAnimating,historyRevealCount]);
@override
String toString() {
return 'LocationMapState(showGeofences: $showGeofences, showFrequentPlaces: $showFrequentPlaces, placingMode: $placingMode, adjustingRadius: $adjustingRadius, previewRadius: $previewRadius, previewPoint: $previewPoint, selectedGeofence: $selectedGeofence, editingGeofence: $editingGeofence, selectedFrequentPlace: $selectedFrequentPlace, selectedHistoryPosition: $selectedHistoryPosition, isFollowing: $isFollowing, actionsExpanded: $actionsExpanded, frequencyExpanded: $frequencyExpanded, mapZoom: $mapZoom)';
return 'LocationMapState(showGeofences: $showGeofences, showFrequentPlaces: $showFrequentPlaces, placingMode: $placingMode, adjustingRadius: $adjustingRadius, previewRadius: $previewRadius, previewPoint: $previewPoint, selectedGeofence: $selectedGeofence, editingGeofence: $editingGeofence, selectedFrequentPlace: $selectedFrequentPlace, editingFrequentPlace: $editingFrequentPlace, selectedHistoryPosition: $selectedHistoryPosition, isFollowing: $isFollowing, actionsExpanded: $actionsExpanded, frequencyExpanded: $frequencyExpanded, mapZoom: $mapZoom, historyNavigationIndex: $historyNavigationIndex, historyPlaying: $historyPlaying, isRevealAnimating: $isRevealAnimating, historyRevealCount: $historyRevealCount)';
}
@@ -315,11 +337,11 @@ abstract mixin class _$LocationMapStateCopyWith<$Res> implements $LocationMapSta
factory _$LocationMapStateCopyWith(_LocationMapState value, $Res Function(_LocationMapState) _then) = __$LocationMapStateCopyWithImpl;
@override @useResult
$Res call({
bool showGeofences, bool showFrequentPlaces, PlacingMode placingMode, bool adjustingRadius, double previewRadius, LatLng? previewPoint, GeofenceEntity? selectedGeofence, GeofenceEntity? editingGeofence, FrequentPlaceEntity? selectedFrequentPlace, PositionEntity? selectedHistoryPosition, bool isFollowing, bool actionsExpanded, bool frequencyExpanded, double mapZoom
bool showGeofences, bool showFrequentPlaces, PlacingMode placingMode, bool adjustingRadius, double previewRadius, LatLng? previewPoint, GeofenceEntity? selectedGeofence, GeofenceEntity? editingGeofence, FrequentPlaceEntity? selectedFrequentPlace, FrequentPlaceEntity? editingFrequentPlace, PositionEntity? selectedHistoryPosition, bool isFollowing, bool actionsExpanded, bool frequencyExpanded, double mapZoom, int historyNavigationIndex, bool historyPlaying, bool isRevealAnimating, int? historyRevealCount
});
@override $GeofenceEntityCopyWith<$Res>? get selectedGeofence;@override $GeofenceEntityCopyWith<$Res>? get editingGeofence;@override $FrequentPlaceEntityCopyWith<$Res>? get selectedFrequentPlace;@override $PositionEntityCopyWith<$Res>? get selectedHistoryPosition;
@override $GeofenceEntityCopyWith<$Res>? get selectedGeofence;@override $GeofenceEntityCopyWith<$Res>? get editingGeofence;@override $FrequentPlaceEntityCopyWith<$Res>? get selectedFrequentPlace;@override $FrequentPlaceEntityCopyWith<$Res>? get editingFrequentPlace;@override $PositionEntityCopyWith<$Res>? get selectedHistoryPosition;
}
/// @nodoc
@@ -332,7 +354,7 @@ class __$LocationMapStateCopyWithImpl<$Res>
/// Create a copy of LocationMapState
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? showGeofences = null,Object? showFrequentPlaces = null,Object? placingMode = null,Object? adjustingRadius = null,Object? previewRadius = null,Object? previewPoint = freezed,Object? selectedGeofence = freezed,Object? editingGeofence = freezed,Object? selectedFrequentPlace = freezed,Object? selectedHistoryPosition = freezed,Object? isFollowing = null,Object? actionsExpanded = null,Object? frequencyExpanded = null,Object? mapZoom = null,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? showGeofences = null,Object? showFrequentPlaces = null,Object? placingMode = null,Object? adjustingRadius = null,Object? previewRadius = null,Object? previewPoint = freezed,Object? selectedGeofence = freezed,Object? editingGeofence = freezed,Object? selectedFrequentPlace = freezed,Object? editingFrequentPlace = freezed,Object? selectedHistoryPosition = freezed,Object? isFollowing = null,Object? actionsExpanded = null,Object? frequencyExpanded = null,Object? mapZoom = null,Object? historyNavigationIndex = null,Object? historyPlaying = null,Object? isRevealAnimating = null,Object? historyRevealCount = freezed,}) {
return _then(_LocationMapState(
showGeofences: null == showGeofences ? _self.showGeofences : showGeofences // ignore: cast_nullable_to_non_nullable
as bool,showFrequentPlaces: null == showFrequentPlaces ? _self.showFrequentPlaces : showFrequentPlaces // ignore: cast_nullable_to_non_nullable
@@ -343,12 +365,17 @@ as double,previewPoint: freezed == previewPoint ? _self.previewPoint : previewPo
as LatLng?,selectedGeofence: freezed == selectedGeofence ? _self.selectedGeofence : selectedGeofence // ignore: cast_nullable_to_non_nullable
as GeofenceEntity?,editingGeofence: freezed == editingGeofence ? _self.editingGeofence : editingGeofence // ignore: cast_nullable_to_non_nullable
as GeofenceEntity?,selectedFrequentPlace: freezed == selectedFrequentPlace ? _self.selectedFrequentPlace : selectedFrequentPlace // ignore: cast_nullable_to_non_nullable
as FrequentPlaceEntity?,editingFrequentPlace: freezed == editingFrequentPlace ? _self.editingFrequentPlace : editingFrequentPlace // ignore: cast_nullable_to_non_nullable
as FrequentPlaceEntity?,selectedHistoryPosition: freezed == selectedHistoryPosition ? _self.selectedHistoryPosition : selectedHistoryPosition // ignore: cast_nullable_to_non_nullable
as PositionEntity?,isFollowing: null == isFollowing ? _self.isFollowing : isFollowing // ignore: cast_nullable_to_non_nullable
as bool,actionsExpanded: null == actionsExpanded ? _self.actionsExpanded : actionsExpanded // ignore: cast_nullable_to_non_nullable
as bool,frequencyExpanded: null == frequencyExpanded ? _self.frequencyExpanded : frequencyExpanded // ignore: cast_nullable_to_non_nullable
as bool,mapZoom: null == mapZoom ? _self.mapZoom : mapZoom // ignore: cast_nullable_to_non_nullable
as double,
as double,historyNavigationIndex: null == historyNavigationIndex ? _self.historyNavigationIndex : historyNavigationIndex // ignore: cast_nullable_to_non_nullable
as int,historyPlaying: null == historyPlaying ? _self.historyPlaying : historyPlaying // ignore: cast_nullable_to_non_nullable
as bool,isRevealAnimating: null == isRevealAnimating ? _self.isRevealAnimating : isRevealAnimating // ignore: cast_nullable_to_non_nullable
as bool,historyRevealCount: freezed == historyRevealCount ? _self.historyRevealCount : historyRevealCount // ignore: cast_nullable_to_non_nullable
as int?,
));
}
@@ -392,6 +419,18 @@ $FrequentPlaceEntityCopyWith<$Res>? get selectedFrequentPlace {
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$FrequentPlaceEntityCopyWith<$Res>? get editingFrequentPlace {
if (_self.editingFrequentPlace == null) {
return null;
}
return $FrequentPlaceEntityCopyWith<$Res>(_self.editingFrequentPlace!, (value) {
return _then(_self.copyWith(editingFrequentPlace: value));
});
}/// Create a copy of LocationMapState
/// with the given fields replaced by the non-null parameter values.
@override
@pragma('vm:prefer-inline')
$PositionEntityCopyWith<$Res>? get selectedHistoryPosition {
if (_self.selectedHistoryPosition == null) {
return null;

View File

@@ -14,8 +14,15 @@ class LocationListSheet extends ConsumerStatefulWidget {
final List<FrequentPlaceEntity> frequentPlaces;
final List<PositionEntity> positionHistory;
final Color primaryColor;
final bool hasHiddenGeofences;
final bool hasHiddenFrequentPlaces;
final bool hasHiddenHistory;
final ValueChanged<GeofenceEntity> onGeofenceTap;
final ValueChanged<GeofenceEntity>? onGeofenceEdit;
final ValueChanged<GeofenceEntity>? onGeofenceDelete;
final ValueChanged<FrequentPlaceEntity> onFrequentPlaceTap;
final ValueChanged<FrequentPlaceEntity>? onFrequentPlaceEdit;
final ValueChanged<FrequentPlaceEntity>? onFrequentPlaceDelete;
final ValueChanged<PositionEntity> onHistoryTap;
const LocationListSheet({
@@ -24,8 +31,15 @@ class LocationListSheet extends ConsumerStatefulWidget {
required this.frequentPlaces,
required this.positionHistory,
required this.primaryColor,
this.hasHiddenGeofences = false,
this.hasHiddenFrequentPlaces = false,
this.hasHiddenHistory = false,
required this.onGeofenceTap,
this.onGeofenceEdit,
this.onGeofenceDelete,
required this.onFrequentPlaceTap,
this.onFrequentPlaceEdit,
this.onFrequentPlaceDelete,
required this.onHistoryTap,
});
@@ -34,6 +48,11 @@ class LocationListSheet extends ConsumerStatefulWidget {
}
class _LocationListSheetState extends ConsumerState<LocationListSheet> {
bool get _hasHiddenItems =>
widget.hasHiddenGeofences ||
widget.hasHiddenFrequentPlaces ||
widget.hasHiddenHistory;
List<PositionEntity> _filterHistory(String? selectedType) {
if (selectedType == null) return widget.positionHistory;
return widget.positionHistory
@@ -90,6 +109,12 @@ class _LocationListSheetState extends ConsumerState<LocationListSheet> {
color: Colors.blue,
icon: Icons.shield,
onTap: () => widget.onGeofenceTap(g),
onEdit: widget.onGeofenceEdit != null
? () => widget.onGeofenceEdit!(g)
: null,
onDelete: widget.onGeofenceDelete != null
? () => widget.onGeofenceDelete!(g)
: null,
),
),
const SizedBox(height: 16),
@@ -111,6 +136,12 @@ class _LocationListSheetState extends ConsumerState<LocationListSheet> {
color: Colors.orange,
icon: Icons.home_rounded,
onTap: () => widget.onFrequentPlaceTap(fp),
onEdit: widget.onFrequentPlaceEdit != null
? () => widget.onFrequentPlaceEdit!(fp)
: null,
onDelete: widget.onFrequentPlaceDelete != null
? () => widget.onFrequentPlaceDelete!(fp)
: null,
),
),
const SizedBox(height: 16),
@@ -136,11 +167,15 @@ class _LocationListSheetState extends ConsumerState<LocationListSheet> {
padding: const EdgeInsets.only(top: 40),
child: Center(
child: Text(
context.translate(I18n.locationListNoItems),
_hasHiddenItems
? context.translate(
I18n.locationListHiddenItems)
: context.translate(I18n.locationListNoItems),
style: const TextStyle(
color: Colors.grey,
fontSize: 14,
),
textAlign: TextAlign.center,
),
),
),
@@ -273,6 +308,8 @@ class _LocationListSheetState extends ConsumerState<LocationListSheet> {
required Color color,
required IconData icon,
required VoidCallback onTap,
VoidCallback? onEdit,
VoidCallback? onDelete,
}) {
return Card(
margin: const EdgeInsets.only(bottom: 6),
@@ -302,11 +339,41 @@ class _LocationListSheetState extends ConsumerState<LocationListSheet> {
subtitle,
style: TextStyle(fontSize: 11, color: Theme.of(context).colorScheme.onSurfaceVariant),
),
trailing: Icon(
Icons.chevron_right,
color: Theme.of(context).colorScheme.outline,
size: 20,
),
trailing: onEdit != null || onDelete != null
? Row(
mainAxisSize: MainAxisSize.min,
children: [
if (onEdit != null)
GestureDetector(
onTap: onEdit,
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
Icons.edit_outlined,
size: 18,
color: color,
),
),
),
if (onDelete != null)
GestureDetector(
onTap: onDelete,
child: Padding(
padding: const EdgeInsets.all(6),
child: Icon(
Icons.delete_outline,
size: 18,
color: Theme.of(context).colorScheme.error,
),
),
),
],
)
: Icon(
Icons.chevron_right,
color: Theme.of(context).colorScheme.outline,
size: 20,
),
onTap: onTap,
),
);

View File

@@ -20,11 +20,15 @@ import 'package:utils/utils.dart';
import 'device_banner.dart';
import 'name_input_sheet.dart';
import 'location_list_sheet.dart';
import 'map_controls/layer_toggles.dart';
import 'map_controls/gps_control_panel.dart';
import 'map_controls/history_player.dart';
import 'map_controls/map_action_button.dart';
import 'map_controls/gps_functions_dialog.dart';
import 'map_controls/map_actions_panel.dart';
import 'map_controls/map_style_selector.dart';
import 'map_controls/placement_banner.dart';
import 'map_controls/radius_slider_bar.dart';
import 'map_controls/reveal_progress_bar.dart';
import 'map_info_cards/frequent_place_info_card.dart';
import 'map_info_cards/geofence_info_card.dart';
import 'map_info_cards/history_position_info_card.dart';
@@ -77,14 +81,17 @@ class _LocationMapState extends ConsumerState<LocationMap>
LocationMapController get _vm =>
ref.read(locationMapControllerProvider.notifier);
Color get _primaryColor =>
context.sfColors.legacyPrimary;
Color get _primaryColor => context.sfColors.legacyPrimary;
@override
void initState() {
super.initState();
_mapController = MapController();
_startMonitoring();
_vm.onPlaybackTick = () {
final state = ref.read(locationMapControllerProvider);
_navigateToHistoryPosition(state.historyNavigationIndex);
};
}
static const _minFrequency = 60;
@@ -94,7 +101,9 @@ class _LocationMapState extends ConsumerState<LocationMap>
final raw = widget.selectedDevice?.settings.frequency ?? 60;
final frequency = raw < _minFrequency ? _minFrequency : raw;
_followTimer = Timer.periodic(Duration(seconds: frequency), (_) {
if (ref.read(selectedDeviceProvider).value?.isDisconnected ?? true) return;
if (ref.read(selectedDeviceProvider).value?.isDisconnected ?? true) {
return;
}
widget.onRefreshPosition();
});
}
@@ -102,6 +111,9 @@ class _LocationMapState extends ConsumerState<LocationMap>
@override
void dispose() {
_followTimer?.cancel();
_vm.onPlaybackTick = null;
_vm.stopPlaybackTimer();
_vm.cancelRecenter();
_moveAnimation?.dispose();
_mapController.dispose();
super.dispose();
@@ -131,12 +143,80 @@ class _LocationMapState extends ConsumerState<LocationMap>
(oldWidget.positionHistory.length != widget.positionHistory.length ||
!oldWidget.showRouteTrail)) {
_fitHistoryBounds();
if (oldWidget.positionHistory.length != widget.positionHistory.length) {
Future(() {
_vm.startHistoryNavigation();
_startRevealAnimation();
});
}
}
}
void _animatedMove(LatLng dest, double zoom) {
void _animatedMove(
LatLng dest,
double? zoom, {
Duration duration = const Duration(milliseconds: 400),
Curve curve = Curves.easeInOut,
}) {
_animatedMoveAsync(dest, zoom, duration: duration, curve: curve);
}
Future<void> _startRevealAnimation() async {
final positions = widget.positionHistory
.where((p) => p.latitude != 0 || p.longitude != 0)
.toList();
if (positions.length <= 1) return;
_vm.startReveal();
await _animatedMoveAsync(
LatLng(positions.first.latitude, positions.first.longitude),
18.0,
duration: const Duration(milliseconds: 1000),
curve: Curves.easeOutCubic,
);
for (var i = 1; i < positions.length; i++) {
if (!mounted) return;
final state = ref.read(locationMapControllerProvider);
if (!state.isRevealAnimating) return;
_vm.updateRevealCount(i + 1);
await _animatedMoveAsync(
LatLng(positions[i].latitude, positions[i].longitude),
null,
duration: const Duration(milliseconds: 800),
curve: Curves.easeInOutCubic,
);
if (!mounted) return;
if (!ref.read(locationMapControllerProvider).isRevealAnimating) return;
await Future.delayed(const Duration(milliseconds: 200));
}
if (!mounted) return;
_vm.finishReveal();
await Future.delayed(const Duration(milliseconds: 500));
if (!mounted) return;
_fitHistoryBounds();
}
void _skipRevealAnimation() {
_moveAnimation?.dispose();
_moveAnimation = null;
_vm.finishReveal();
Future(_vm.startHistoryNavigation);
_fitHistoryBounds();
}
Future<void> _animatedMoveAsync(
LatLng dest,
double? zoom, {
Duration duration = const Duration(milliseconds: 400),
Curve curve = Curves.easeInOut,
}) {
final completer = Completer<void>();
_moveAnimation?.dispose();
final camera = _mapController.camera;
final targetZoom = zoom ?? camera.zoom;
final latTween = Tween<double>(
begin: camera.center.latitude,
end: dest.latitude,
@@ -145,18 +225,12 @@ class _LocationMapState extends ConsumerState<LocationMap>
begin: camera.center.longitude,
end: dest.longitude,
);
final zoomTween = Tween<double>(begin: camera.zoom, end: zoom);
final zoomTween = Tween<double>(begin: camera.zoom, end: targetZoom);
final controller = AnimationController(
duration: const Duration(milliseconds: 400),
vsync: this,
);
final controller = AnimationController(duration: duration, vsync: this);
_moveAnimation = controller;
final animation = CurvedAnimation(
parent: controller,
curve: Curves.easeInOut,
);
final animation = CurvedAnimation(parent: controller, curve: curve);
controller.addListener(() {
_mapController.move(
@@ -170,10 +244,53 @@ class _LocationMapState extends ConsumerState<LocationMap>
status == AnimationStatus.dismissed) {
controller.dispose();
if (_moveAnimation == controller) _moveAnimation = null;
if (!completer.isCompleted) completer.complete();
}
});
controller.forward();
return completer.future;
}
bool _isHistoryNavigating(LocationMapState mapState) =>
widget.showRouteTrail &&
widget.positionHistory.isNotEmpty &&
mapState.historyNavigationIndex >= 0 &&
mapState.historyNavigationIndex < widget.positionHistory.length;
void _navigateToHistoryPosition(int index) {
if (index < 0 || index >= widget.positionHistory.length) return;
final position = widget.positionHistory[index];
if (position.latitude == 0 && position.longitude == 0) return;
_animatedMove(LatLng(position.latitude, position.longitude), null);
}
Future<void> _handleToggleFollow(LocationMapState mapState) async {
final willActivate = !mapState.isFollowing;
if (willActivate && !await guardDeviceCommand(context, ref)) return;
if (!mounted) return;
_vm.toggleFollowing();
unawaited(
ref
.read(sfTrackingProvider)
.legacyLocationMapFollowToggled(willActivate),
);
if (willActivate && widget.selectedPosition != null) {
_centerOnDevice();
}
}
void _togglePlayback() {
_vm.toggleHistoryPlayback(widget.positionHistory.length);
final state = ref.read(locationMapControllerProvider);
if (state.historyPlaying) {
_navigateToHistoryPosition(state.historyNavigationIndex);
}
}
void _scheduleRecenter() {
if (widget.selectedPosition == null) return;
_vm.scheduleRecenter(_centerOnDevice);
}
void _centerOnDevice() {
@@ -245,6 +362,46 @@ class _LocationMapState extends ConsumerState<LocationMap>
);
}
Future<void> _showGpsFunctionsDialog(
BuildContext context,
WidgetRef ref,
LocationMapState mapState,
) async {
final result = await showGpsFunctionsDialog(
context,
isFollowing: mapState.isFollowing,
showGeofences: mapState.showGeofences,
showFrequentPlaces: mapState.showFrequentPlaces,
showRouteTrail: widget.showRouteTrail,
hasPositionHistory: widget.positionHistory.isNotEmpty,
hasPosition: widget.selectedPosition != null,
);
if (result == null || !mounted) return;
switch (result) {
case GpsFunction.list:
_showListSheet();
case GpsFunction.addGeofence:
_vm.startPlacing(PlacingMode.geofence);
case GpsFunction.addFrequentPlace:
_vm.startPlacing(PlacingMode.frequentPlace);
case GpsFunction.share:
_shareLocation();
case GpsFunction.centerOnDevice:
_centerOnDevice();
case GpsFunction.toggleFollow:
_handleToggleFollow(mapState);
case GpsFunction.toggleGeofences:
_vm.toggleGeofences();
case GpsFunction.toggleFrequentPlaces:
_vm.toggleFrequentPlaces();
case GpsFunction.positionHistory:
_handleHistoryTap();
case GpsFunction.toggleRouteTrail:
ref.read(locationControllerProvider.notifier).toggleRouteTrail();
}
}
Future<void> _updateFrequency(int frequency) async {
if (!await guardDeviceCommand(context, ref)) return;
final success = await ref
@@ -253,7 +410,9 @@ class _LocationMapState extends ConsumerState<LocationMap>
if (!mounted) return;
if (success) {
_followTimer?.cancel();
final safeFrequency = frequency < _minFrequency ? _minFrequency : frequency;
final safeFrequency = frequency < _minFrequency
? _minFrequency
: frequency;
_followTimer = Timer.periodic(Duration(seconds: safeFrequency), (_) {
widget.onRefreshPosition();
});
@@ -274,19 +433,41 @@ class _LocationMapState extends ConsumerState<LocationMap>
if (mapState.placingMode == PlacingMode.geofence) {
_vm.confirmGeofencePlacement(center);
} else {
final editing = mapState.editingFrequentPlace;
_vm.confirmFrequentPlacePlacement();
showNameInputSheet(
context,
ref: ref,
title: context.translate(I18n.locationNewFrequentPlace),
title: context.translate(
editing != null
? I18n.locationEditFrequentPlace
: I18n.locationNewFrequentPlace,
),
hintText: context.translate(I18n.locationHintFrequentPlace),
onSubmit: (name, _) => ref
.read(locationControllerProvider.notifier)
.createFrequentPlace(
name: name,
lat: center.latitude,
lng: center.longitude,
),
initialName: editing?.name,
submitLabel: editing != null
? context.translate(I18n.locationSave)
: null,
onSubmit: (name, _) {
if (editing != null) {
return ref
.read(locationControllerProvider.notifier)
.updateFrequentPlace(
id: editing.id,
name: name,
lat: center.latitude,
lng: center.longitude,
wifiList: editing.wifiList,
);
}
return ref
.read(locationControllerProvider.notifier)
.createFrequentPlace(
name: name,
lat: center.latitude,
lng: center.longitude,
);
},
);
}
}
@@ -356,15 +537,57 @@ class _LocationMapState extends ConsumerState<LocationMap>
positionHistory: locationState.showRouteTrail
? locationState.positionHistory
: [],
hasHiddenGeofences:
!mapState.showGeofences && locationState.geofences.isNotEmpty,
hasHiddenFrequentPlaces:
!mapState.showFrequentPlaces &&
locationState.frequentPlaces.isNotEmpty,
hasHiddenHistory:
!locationState.showRouteTrail &&
locationState.positionHistory.isNotEmpty,
primaryColor: _primaryColor,
onGeofenceTap: (g) {
Navigator.pop(context);
_animatedMove(LatLng(g.latitude, g.longitude), _defaultZoom);
},
onGeofenceEdit: (g) {
Navigator.pop(context);
_vm.selectGeofence(g);
},
onGeofenceDelete: (g) async {
Navigator.pop(context);
final confirmed = await _confirmDelete(
context,
I18n.locationDeleteGeofenceConfirm,
);
if (!confirmed || !mounted) return;
if (!await guardDeviceCommand(context, ref)) return;
if (!mounted) return;
ref
.read(locationControllerProvider.notifier)
.deleteGeofence(id: g.id);
},
onFrequentPlaceTap: (fp) {
Navigator.pop(context);
_animatedMove(LatLng(fp.lat, fp.lng), _defaultZoom);
},
onFrequentPlaceEdit: (fp) {
Navigator.pop(context);
_onEditFrequentPlace(fp);
},
onFrequentPlaceDelete: (fp) async {
Navigator.pop(context);
final confirmed = await _confirmDelete(
context,
I18n.locationDeleteFrequentPlaceConfirm,
);
if (!confirmed || !mounted) return;
if (!await guardDeviceCommand(context, ref)) return;
if (!mounted) return;
ref
.read(locationControllerProvider.notifier)
.deleteFrequentPlace(id: fp.id);
},
onHistoryTap: (p) {
Navigator.pop(context);
if (p.latitude != 0 || p.longitude != 0) {
@@ -375,12 +598,34 @@ class _LocationMapState extends ConsumerState<LocationMap>
);
}
Future<bool> _confirmDelete(BuildContext context, String messageKey) async {
final result = await showLegacyDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(context.translate(I18n.delete)),
content: Text(context.translate(messageKey)),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(context.translate(I18n.cancel)),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: Text(context.translate(I18n.delete)),
),
],
),
);
return result == true;
}
void _handleHistoryTap() {
if (widget.positionHistory.isEmpty) {
_openDateRangePicker();
} else {
ref.read(locationControllerProvider.notifier).toggleRouteTrail();
}
_vm.stopPlaybackTimer();
_vm.stopHistoryNavigation();
_openDateRangePicker();
}
Future<void> _openDateRangePicker() async {
@@ -394,12 +639,7 @@ class _LocationMapState extends ConsumerState<LocationMap>
start: now.subtract(const Duration(days: 1)),
end: now,
),
builder: (context, child) => Theme(
data: Theme.of(
context,
).copyWith(colorScheme: ColorScheme.light(primary: _primaryColor)),
child: child!,
),
builder: legacyPickerThemeBuilder,
);
if (picked != null) {
@@ -411,33 +651,31 @@ class _LocationMapState extends ConsumerState<LocationMap>
59,
59,
);
ref
if (!mounted) return;
showInfoDialog(context, I18n.locationHistoryLoading);
await ref
.read(locationControllerProvider.notifier)
.loadPositionHistory(from: picked.start, to: to);
if (!mounted) return;
final history =
ref.read(locationControllerProvider).value?.positionHistory ?? [];
if (history.isEmpty) {
await showErrorDialog(context, I18n.locationHistoryEmpty);
} else {
await showSuccessDialog(
context,
I18n.locationHistoryLoaded,
args: {'count': history.length},
);
}
}
}
Future<void> _onEditFrequentPlace(FrequentPlaceEntity fp) async {
if (!await guardDeviceCommand(context, ref)) return;
if (!mounted) return;
_vm.clearSelectedFrequentPlace();
showNameInputSheet(
context,
ref: ref,
title: context.translate(I18n.locationEditFrequentPlace),
hintText: context.translate(I18n.locationHintFrequentPlace),
initialName: fp.name,
submitLabel: context.translate(I18n.locationSave),
onSubmit: (name, _) => ref
.read(locationControllerProvider.notifier)
.updateFrequentPlace(
id: fp.id,
name: name,
lat: fp.lat,
lng: fp.lng,
wifiList: fp.wifiList,
),
);
_vm.startEditingFrequentPlace(fp);
_animatedMove(LatLng(fp.lat, fp.lng), null);
}
List<Widget> _buildMapLayers(LocationMapState mapState) {
@@ -448,6 +686,8 @@ class _LocationMapState extends ConsumerState<LocationMap>
zoom: mapState.mapZoom,
onPositionTap: _vm.selectHistoryPosition,
onClusterTap: _animatedMove,
activeIndex: mapState.historyNavigationIndex,
visibleCount: mapState.historyRevealCount,
)
: null;
@@ -488,17 +728,68 @@ class _LocationMapState extends ConsumerState<LocationMap>
],
),
MarkerLayer(markers: _buildMarkers(mapState, historyLayer)),
if (widget.selectedDevice != null)
Align(
alignment: Alignment.bottomCenter,
child: DeviceBanner(
device: widget.selectedDevice!,
devices: widget.devices,
positions: widget.positions,
onDeviceChanged: widget.onDeviceChanged,
onTap: _centerOnDevice,
if (mapState.isRevealAnimating) ...[
Positioned(
bottom: SizeUtils.getByScreen(small: 16, big: 14),
left: 0,
right: 0,
child: RevealProgressBar(
current: mapState.historyRevealCount ?? 0,
total: widget.positionHistory.length,
primaryColor: _primaryColor,
onSkip: _skipRevealAnimation,
),
),
] else if (_isHistoryNavigating(mapState)) ...[
Positioned(
bottom: SizeUtils.getByScreen(small: 16, big: 14),
left: 0,
right: 0,
child: HistoryPlayer(
currentIndex: mapState.historyNavigationIndex,
total: widget.positionHistory.length,
isPlaying: mapState.historyPlaying,
currentPosition:
widget.positionHistory[mapState.historyNavigationIndex],
onPrevious: () {
_vm.previousHistoryPosition();
final state = ref.read(locationMapControllerProvider);
_navigateToHistoryPosition(state.historyNavigationIndex);
},
onNext: () {
_vm.nextHistoryPosition(widget.positionHistory.length);
final state = ref.read(locationMapControllerProvider);
_navigateToHistoryPosition(state.historyNavigationIndex);
},
onPlayPause: _togglePlayback,
onClose: () {
_vm.stopPlaybackTimer();
_vm.stopHistoryNavigation();
},
),
),
] else ...[
if (widget.selectedPosition != null)
Positioned(
bottom: SizeUtils.getByScreen(small: 190, big: 186),
right: 16,
child: MapActionButton(
icon: Icons.my_location,
onTap: _centerOnDevice,
),
),
if (widget.selectedDevice != null)
Align(
alignment: Alignment.bottomCenter,
child: DeviceBanner(
device: widget.selectedDevice!,
devices: widget.devices,
positions: widget.positions,
onDeviceChanged: widget.onDeviceChanged,
onTap: _centerOnDevice,
),
),
],
];
}
@@ -538,7 +829,7 @@ class _LocationMapState extends ConsumerState<LocationMap>
width: 40,
height: 40,
child: GestureDetector(
onLongPress: () => _vm.selectFrequentPlace(fp),
onTap: () => _vm.selectFrequentPlace(fp),
child: Container(
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.3),
@@ -567,7 +858,7 @@ class _LocationMapState extends ConsumerState<LocationMap>
width: 36,
height: 36,
child: GestureDetector(
onLongPress: () => _vm.selectGeofence(g),
onTap: () => _vm.selectGeofence(g),
child: Container(
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.3),
@@ -643,7 +934,12 @@ class _LocationMapState extends ConsumerState<LocationMap>
const SizedBox(height: 8),
FrequencySelector(
currentFrequency:
ref.watch(selectedDeviceProvider).value?.settings.frequency ?? 60,
ref
.watch(selectedDeviceProvider)
.value
?.settings
.frequency ??
60,
options: widget.selectedDevice!.capabilities!.location!.options
.where((o) => o > 0 || o == -1)
.toList(),
@@ -656,31 +952,8 @@ class _LocationMapState extends ConsumerState<LocationMap>
Positioned(
top: 12,
right: 12,
child: LayerToggles(
showGeofences: mapState.showGeofences,
showFrequentPlaces: mapState.showFrequentPlaces,
showRouteTrail: widget.showRouteTrail,
hasPositionHistory: widget.positionHistory.isNotEmpty,
isLoadingHistory: widget.isLoadingHistory,
onGeofencesToggled: _vm.toggleGeofences,
onFrequentPlacesToggled: _vm.toggleFrequentPlaces,
onHistoryTapped: _handleHistoryTap,
onHistoryLongPressed: _openDateRangePicker,
),
),
Positioned(
bottom: SizeUtils.getByScreen(small: 120, big: 110),
right: 12,
child: MapActionsPanel(
actionsExpanded: mapState.actionsExpanded,
hasPosition: widget.selectedPosition != null,
isFollowing: mapState.isFollowing,
onToggleExpanded: _vm.toggleActionsExpanded,
onListTap: _showListSheet,
onAddGeofence: () => _vm.startPlacing(PlacingMode.geofence),
onAddFrequentPlace: () => _vm.startPlacing(PlacingMode.frequentPlace),
onShareTap: _shareLocation,
onRefreshTap: () async {
child: GpsControlPanel(
onRefresh: () async {
if (!await guardDeviceCommand(context, ref)) return;
unawaited(
ref.read(sfTrackingProvider).legacyLocationMapRefreshTapped(),
@@ -694,27 +967,7 @@ class _LocationMapState extends ConsumerState<LocationMap>
await showErrorDialog(context, I18n.errorPositions);
}
},
onCenterTap: _centerOnDevice,
onToggleFollow: () async {
final willActivate = !mapState.isFollowing;
if (willActivate && !await guardDeviceCommand(context, ref)) return;
_vm.toggleFollowing();
unawaited(
ref
.read(sfTrackingProvider)
.legacyLocationMapFollowToggled(willActivate),
);
if (willActivate && widget.selectedPosition != null) {
_centerOnDevice();
}
if (!mounted) return;
await showSuccessDialog(
context,
willActivate
? I18n.locationMapFollowEnabled
: I18n.locationMapFollowDisabled,
);
},
onSettings: () => _showGpsFunctionsDialog(context, ref, mapState),
),
),
];
@@ -791,10 +1044,13 @@ class _LocationMapState extends ConsumerState<LocationMap>
initialZoom: initialZoom,
minZoom: 5,
keepAlive: true,
onPositionChanged: (camera, _) {
onPositionChanged: (camera, hasGesture) {
if (widget.showRouteTrail && widget.positionHistory.length > 2) {
_vm.updateMapZoom(camera.zoom);
}
if (hasGesture) {
_scheduleRecenter();
}
},
),
children: _buildMapLayers(mapState),

View File

@@ -0,0 +1,77 @@
import 'package:legacy_theme/legacy_theme.dart';
import 'package:flutter/material.dart';
class GpsControlPanel extends StatelessWidget {
final VoidCallback onRefresh;
final VoidCallback onSettings;
const GpsControlPanel({
super.key,
required this.onRefresh,
required this.onSettings,
});
@override
Widget build(BuildContext context) {
final primaryColor = context.sfColors.legacyPrimary;
return Container(
decoration: BoxDecoration(
color: primaryColor,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.all(6),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_PanelButton(
icon: Icons.refresh,
onTap: onRefresh,
),
const SizedBox(height: 4),
_PanelButton(
icon: Icons.settings,
onTap: onSettings,
),
],
),
);
}
}
class _PanelButton extends StatelessWidget {
final IconData icon;
final VoidCallback onTap;
const _PanelButton({
required this.icon,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
icon,
color: Colors.white,
size: 24,
),
),
);
}
}

View File

@@ -0,0 +1,179 @@
import 'package:legacy_theme/legacy_theme.dart';
import 'package:legacy_ui/legacy_ui.dart';
import 'package:flutter/material.dart';
import 'package:sf_localizations/sf_localizations.dart';
enum GpsFunction {
list,
addGeofence,
addFrequentPlace,
share,
centerOnDevice,
toggleFollow,
toggleGeofences,
toggleFrequentPlaces,
positionHistory,
toggleRouteTrail,
}
Future<GpsFunction?> showGpsFunctionsDialog(
BuildContext context, {
required bool isFollowing,
required bool showGeofences,
required bool showFrequentPlaces,
required bool showRouteTrail,
required bool hasPositionHistory,
required bool hasPosition,
}) {
return showLegacyDialog<GpsFunction>(
context: context,
builder: (dialogContext) => _GpsFunctionsDialog(
isFollowing: isFollowing,
showGeofences: showGeofences,
showFrequentPlaces: showFrequentPlaces,
showRouteTrail: showRouteTrail,
hasPositionHistory: hasPositionHistory,
hasPosition: hasPosition,
),
);
}
class _GpsFunctionsDialog extends StatelessWidget {
final bool isFollowing;
final bool showGeofences;
final bool showFrequentPlaces;
final bool showRouteTrail;
final bool hasPositionHistory;
final bool hasPosition;
const _GpsFunctionsDialog({
required this.isFollowing,
required this.showGeofences,
required this.showFrequentPlaces,
required this.showRouteTrail,
required this.hasPositionHistory,
required this.hasPosition,
});
@override
Widget build(BuildContext context) {
final primaryColor = context.sfColors.legacyPrimary;
return AlertDialog(
title: Center(
child: Text(
context.translate(I18n.gpsFunctionsTitle),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: primaryColor,
),
),
),
contentPadding: const EdgeInsets.only(top: 12, bottom: 8),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_FunctionTile(
icon: Icons.list_alt,
label: context.translate(I18n.gpsFunctionList),
onTap: () => Navigator.pop(context, GpsFunction.list),
),
_FunctionTile(
icon: Icons.add_location_alt,
label: context.translate(I18n.gpsFunctionAddGeofence),
onTap: () => Navigator.pop(context, GpsFunction.addGeofence),
visibilityIcon: showGeofences,
onVisibilityTap: () =>
Navigator.pop(context, GpsFunction.toggleGeofences),
),
_FunctionTile(
icon: Icons.add_home,
label: context.translate(I18n.gpsFunctionAddFrequentPlace),
onTap: () =>
Navigator.pop(context, GpsFunction.addFrequentPlace),
visibilityIcon: showFrequentPlaces,
onVisibilityTap: () =>
Navigator.pop(context, GpsFunction.toggleFrequentPlaces),
),
if (hasPosition)
_FunctionTile(
icon: Icons.share,
label: context.translate(I18n.gpsFunctionShare),
onTap: () => Navigator.pop(context, GpsFunction.share),
),
if (hasPosition)
_FunctionTile(
icon: isFollowing ? Icons.gps_fixed : Icons.gps_not_fixed,
label: context.translate(
isFollowing
? I18n.gpsFunctionStopFollow
: I18n.gpsFunctionFollow,
),
onTap: () => Navigator.pop(context, GpsFunction.toggleFollow),
),
_FunctionTile(
icon: Icons.route,
label: context.translate(I18n.locationLayerHistory),
onTap: () =>
Navigator.pop(context, GpsFunction.positionHistory),
visibilityIcon:
hasPositionHistory ? showRouteTrail : null,
onVisibilityTap: hasPositionHistory
? () =>
Navigator.pop(context, GpsFunction.toggleRouteTrail)
: null,
),
],
),
),
);
}
}
class _FunctionTile extends StatelessWidget {
final IconData icon;
final String label;
final VoidCallback onTap;
final bool? visibilityIcon;
final VoidCallback? onVisibilityTap;
const _FunctionTile({
required this.icon,
required this.label,
required this.onTap,
this.visibilityIcon,
this.onVisibilityTap,
});
@override
Widget build(BuildContext context) {
final primaryColor = context.sfColors.legacyPrimary;
return ListTile(
dense: true,
leading: Icon(icon, color: primaryColor, size: 22),
title: Text(
label,
style: const TextStyle(fontSize: 14),
),
trailing: visibilityIcon != null && onVisibilityTap != null
? GestureDetector(
onTap: onVisibilityTap,
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
visibilityIcon! ? Icons.visibility : Icons.visibility_off,
size: 22,
color: visibilityIcon!
? primaryColor
: Theme.of(context).colorScheme.outline,
),
),
)
: const Icon(Icons.chevron_right, size: 20),
onTap: onTap,
);
}
}

View File

@@ -0,0 +1,199 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:legacy_device_state/legacy_device_state.dart';
import 'package:legacy_theme/legacy_theme.dart';
const _playbackIntervalSeconds = 3;
class HistoryPlayer extends StatelessWidget {
final int currentIndex;
final int total;
final bool isPlaying;
final PositionEntity currentPosition;
final VoidCallback onPrevious;
final VoidCallback onNext;
final VoidCallback onPlayPause;
final VoidCallback onClose;
const HistoryPlayer({
super.key,
required this.currentIndex,
required this.total,
required this.isPlaying,
required this.currentPosition,
required this.onPrevious,
required this.onNext,
required this.onPlayPause,
required this.onClose,
});
@override
Widget build(BuildContext context) {
final primaryColor = context.sfColors.legacyPrimary;
final date = DateTime.fromMillisecondsSinceEpoch(
currentPosition.positionDate,
);
final dateText = DateFormat('dd/MM/yyyy HH:mm').format(date);
final addressParts = [
currentPosition.address?.street,
currentPosition.address?.city,
].where((s) => s != null && s.isNotEmpty && s != 'Unknown').join(', ');
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 8,
offset: const Offset(0, 4),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(Icons.location_on, color: primaryColor, size: 18),
const SizedBox(width: 6),
Expanded(
child: Text(
addressParts.isNotEmpty
? addressParts
: currentPosition.type,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
GestureDetector(
onTap: onClose,
child: Padding(
padding: const EdgeInsets.all(4),
child: Icon(
Icons.close,
size: 20,
color: Theme.of(context).colorScheme.outline,
),
),
),
],
),
const SizedBox(height: 2),
Row(
children: [
const SizedBox(width: 24),
Text(
'$dateText · ${currentPosition.type}',
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
onPressed: currentIndex > 0 ? onPrevious : null,
icon: const Icon(Icons.skip_previous),
color: primaryColor,
iconSize: 28,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
),
const SizedBox(width: 8),
GestureDetector(
onTap: onPlayPause,
child: Stack(
alignment: Alignment.center,
children: [
if (isPlaying)
_CountdownRing(
key: ValueKey(currentIndex),
primaryColor: primaryColor,
),
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: primaryColor,
shape: BoxShape.circle,
),
child: Icon(
isPlaying ? Icons.pause : Icons.play_arrow,
color: Colors.white,
size: 26,
),
),
],
),
),
const SizedBox(width: 8),
IconButton(
onPressed: currentIndex < total - 1 ? onNext : null,
icon: const Icon(Icons.skip_next),
color: primaryColor,
iconSize: 28,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
),
],
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: total > 1 ? currentIndex / (total - 1) : 0,
backgroundColor:
Theme.of(context).colorScheme.outline.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>(primaryColor),
borderRadius: BorderRadius.circular(2),
minHeight: 3,
),
const SizedBox(height: 4),
Text(
'${currentIndex + 1} / $total',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
);
}
}
class _CountdownRing extends StatelessWidget {
final Color primaryColor;
const _CountdownRing({super.key, required this.primaryColor});
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder<double>(
tween: Tween(begin: 1.0, end: 0.0),
duration: const Duration(seconds: _playbackIntervalSeconds),
builder: (context, value, _) => SizedBox(
width: 48,
height: 48,
child: CircularProgressIndicator(
value: value,
strokeWidth: 2.5,
valueColor: AlwaysStoppedAnimation<Color>(primaryColor),
backgroundColor: primaryColor.withValues(alpha: 0.2),
),
),
);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:legacy_theme/legacy_theme.dart';
import 'package:location/src/features/location/presentation/providers/location_map_controller.dart';
import 'package:sf_localizations/sf_localizations.dart';
import 'package:utils/utils.dart';
@@ -120,6 +121,7 @@ class FrequencySelector extends ConsumerWidget {
}
Widget _buildSegmented(BuildContext context, LocationMapController vm) {
final primaryColor = context.sfColors.legacyPrimary;
return Material(
elevation: 2,
borderRadius: BorderRadius.circular(8),
@@ -142,9 +144,7 @@ class FrequencySelector extends ConsumerWidget {
padding: const EdgeInsets.symmetric(horizontal: 6),
margin: const EdgeInsets.symmetric(vertical: 2),
decoration: BoxDecoration(
color: selected
? Theme.of(context).primaryColor
: Colors.transparent,
color: selected ? primaryColor : Colors.transparent,
borderRadius: BorderRadius.circular(6),
),
child: Center(
@@ -156,7 +156,7 @@ class FrequencySelector extends ConsumerWidget {
fontSize: opt == -1 ? 10 : 12,
fontWeight: FontWeight.w600,
color: selected
? Theme.of(context).colorScheme.surface
? Colors.white
: Theme.of(context).colorScheme.onSurface,
),
),
@@ -167,10 +167,10 @@ class FrequencySelector extends ConsumerWidget {
const SizedBox(height: 2),
GestureDetector(
onTap: vm.collapseFrequency,
child: const SizedBox(
child: SizedBox(
width: 40,
height: 24,
child: Icon(Icons.close, size: 16, color: Colors.black54),
child: Icon(Icons.close, size: 16, color: primaryColor),
),
),
],

View File

@@ -34,10 +34,10 @@ class MapStyleSelector extends ConsumerWidget {
onTap: () =>
ref.read(mapStyleSelectorExpandedProvider.notifier).set(true),
borderRadius: BorderRadius.circular(8),
child: const SizedBox(
child: SizedBox(
width: 40,
height: 40,
child: Icon(Icons.layers, size: 22, color: Colors.grey),
child: Icon(Icons.layers, size: 22, color: primaryColor),
),
),
);

View File

@@ -0,0 +1,96 @@
import 'package:flutter/material.dart';
import 'package:sf_localizations/sf_localizations.dart';
class RevealProgressBar extends StatelessWidget {
final int current;
final int total;
final Color primaryColor;
final VoidCallback onSkip;
const RevealProgressBar({
super.key,
required this.current,
required this.total,
required this.primaryColor,
required this.onSkip,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(14),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.12),
blurRadius: 6,
offset: const Offset(0, 3),
),
],
),
child: Row(
children: [
Icon(Icons.route, color: primaryColor, size: 20),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.translate(I18n.locationRevealProgress),
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: total > 1 ? current / total : 0,
backgroundColor: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>(primaryColor),
borderRadius: BorderRadius.circular(2),
minHeight: 3,
),
const SizedBox(height: 2),
Text(
'$current / $total',
style: TextStyle(
fontSize: 10,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: 10),
GestureDetector(
onTap: onSkip,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: primaryColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
context.translate(I18n.locationRevealSkip),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: primaryColor,
),
),
),
),
],
),
);
}
}

View File

@@ -21,6 +21,8 @@ class RouteHistoryLayer extends StatelessWidget {
final double zoom;
final ValueChanged<PositionEntity> onPositionTap;
final void Function(LatLng point, double zoom) onClusterTap;
final int activeIndex;
final int? visibleCount;
const RouteHistoryLayer({
super.key,
@@ -28,19 +30,56 @@ class RouteHistoryLayer extends StatelessWidget {
required this.zoom,
required this.onPositionTap,
required this.onClusterTap,
this.activeIndex = -1,
this.visibleCount,
});
List<PositionEntity> get _visiblePositions {
if (visibleCount == null) return positionHistory;
return positionHistory.take(visibleCount!).toList();
}
@override
Widget build(BuildContext context) {
return MarkerLayer(
markers: [..._buildDirectionArrows(), ..._buildHistoryMarkers()],
markers: [
..._buildDirectionArrows(),
..._buildHistoryMarkers(),
if (activeIndex >= 0 && activeIndex < positionHistory.length)
_buildActiveMarker(positionHistory[activeIndex]),
],
);
}
Marker _buildActiveMarker(PositionEntity position) {
return Marker(
point: LatLng(position.latitude, position.longitude),
width: 40,
height: 40,
child: Container(
decoration: BoxDecoration(
color: routeArrowColor,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 3),
boxShadow: [
BoxShadow(
color: routeArrowColor.withValues(alpha: 0.5),
blurRadius: 8,
spreadRadius: 2,
),
],
),
child: const Icon(Icons.navigation, color: Colors.white, size: 20),
),
rotate: true,
);
}
List<Polyline> buildRouteSegments() {
if (positionHistory.length < 2) return [];
final visible = _visiblePositions;
if (visible.length < 2) return [];
final points = positionHistory
final points = visible
.where(_hasValidCoords)
.map((p) => LatLng(p.latitude, p.longitude))
.toList();
@@ -48,17 +87,18 @@ class RouteHistoryLayer extends StatelessWidget {
if (points.length < 2) return [];
return [
Polyline(points: points, color: routeColor, strokeWidth: 4.0),
Polyline(points: points, color: routeColor, strokeWidth: 5.0),
];
}
List<Marker> _buildDirectionArrows() {
if (positionHistory.length < 2) return [];
final visible = _visiblePositions;
if (visible.length < 2) return [];
final arrows = <Marker>[];
for (int i = 0; i < positionHistory.length - 1; i++) {
final from = positionHistory[i];
final to = positionHistory[i + 1];
for (int i = 0; i < visible.length - 1; i++) {
final from = visible[i];
final to = visible[i + 1];
if (!_hasValidCoords(from) || !_hasValidCoords(to)) continue;
final midLat = (from.latitude + to.latitude) / 2;
@@ -89,14 +129,15 @@ class RouteHistoryLayer extends StatelessWidget {
}
List<Marker> _buildHistoryMarkers() {
if (positionHistory.isEmpty) return [];
final visible = _visiblePositions;
if (visible.isEmpty) return [];
final markers = <Marker>[];
final threshold =
_clusterBaseDegrees / math.pow(2, zoom - _clusterBaseZoom);
final first = positionHistory.first;
final last = positionHistory.last;
final first = visible.first;
final last = visible.last;
markers.add(
_buildEndpointMarker(
@@ -105,7 +146,7 @@ class RouteHistoryLayer extends StatelessWidget {
icon: Icons.play_arrow,
),
);
if (positionHistory.length > 1) {
if (visible.length > 1) {
markers.add(
_buildEndpointMarker(
position: last,
@@ -115,11 +156,11 @@ class RouteHistoryLayer extends StatelessWidget {
);
}
if (positionHistory.length <= 2) return markers;
if (visible.length <= 2) return markers;
final intermediates = positionHistory.sublist(
final intermediates = visible.sublist(
1,
positionHistory.length - 1,
visible.length - 1,
);
final clustered = <List<int>>[];
final visited = List.filled(intermediates.length, false);
@@ -214,11 +255,11 @@ class RouteHistoryLayer extends StatelessWidget {
}) {
return Marker(
point: LatLng(position.latitude, position.longitude),
width: 18,
height: 18,
width: 28,
height: 28,
child: GestureDetector(
onTap: () => onPositionTap(position),
child: _CircleMarkerIcon(color: color, size: 18),
child: _CircleMarkerIcon(color: color, size: 28),
),
rotate: true,
);