Stabilize location driven review flow
Build and deploy Flutter Web / build (push) Successful in 6m5s

This commit is contained in:
Ruslan Bakiev
2026-06-24 14:19:35 +07:00
parent 739bc5391e
commit bb49a13b65
2 changed files with 120 additions and 43 deletions
@@ -13,6 +13,8 @@ const _unset = Object();
enum PlaceLoadStatus { loading, ready, failure }
enum LocationResolutionStatus { resolving, resolved, unavailable }
class PlaceState {
const PlaceState({
required this.selectedTrait,
@@ -21,6 +23,7 @@ class PlaceState {
required this.currentUser,
required this.hasTelegramAuth,
required this.userCoordinate,
required this.locationStatus,
required this.reviewDraft,
required this.voiceFilterTranscript,
required this.voiceFilterTags,
@@ -34,6 +37,7 @@ class PlaceState {
final AppUser? currentUser;
final bool hasTelegramAuth;
final LatLng? userCoordinate;
final LocationResolutionStatus locationStatus;
final VoiceReviewDraft reviewDraft;
final String voiceFilterTranscript;
final List<String> voiceFilterTags;
@@ -75,6 +79,7 @@ class PlaceState {
Object? currentUser = _unset,
bool? hasTelegramAuth,
Object? userCoordinate = _unset,
LocationResolutionStatus? locationStatus,
VoiceReviewDraft? reviewDraft,
String? voiceFilterTranscript,
List<String>? voiceFilterTags,
@@ -94,6 +99,7 @@ class PlaceState {
userCoordinate: identical(userCoordinate, _unset)
? this.userCoordinate
: userCoordinate as LatLng?,
locationStatus: locationStatus ?? this.locationStatus,
reviewDraft: reviewDraft ?? this.reviewDraft,
voiceFilterTranscript:
voiceFilterTranscript ?? this.voiceFilterTranscript,
@@ -169,6 +175,7 @@ class PlaceCubit extends Cubit<PlaceViewState> {
currentUser: currentUser,
hasTelegramAuth: _authRepository.hasTelegramAuth,
userCoordinate: null,
locationStatus: LocationResolutionStatus.resolving,
reviewDraft: _emptyDraft,
voiceFilterTranscript: '',
voiceFilterTags: const [],
@@ -358,12 +365,21 @@ class PlaceCubit extends Cubit<PlaceViewState> {
}
Future<void> _resolveLocation() async {
final coordinate = await _location.resolve().catchError((Object _) => null);
final coordinate = await _location.resolve();
final value = state.placeState;
if (value == null || coordinate == null) {
if (value == null) {
return;
}
emit(PlaceViewState.ready(value.copyWith(userCoordinate: coordinate)));
emit(
PlaceViewState.ready(
value.copyWith(
userCoordinate: coordinate,
locationStatus: coordinate == null
? LocationResolutionStatus.unavailable
: LocationResolutionStatus.resolved,
),
),
);
}
PlaceState _requireReady() {
@@ -382,6 +398,7 @@ class PlaceCubit extends Cubit<PlaceViewState> {
currentUser: null,
hasTelegramAuth: hasTelegramAuth,
userCoordinate: null,
locationStatus: LocationResolutionStatus.unavailable,
reviewDraft: _emptyDraft,
voiceFilterTranscript: '',
voiceFilterTags: const [],
@@ -329,18 +329,35 @@ class _MapboxMapLayer extends StatefulWidget {
}
class _MapboxMapLayerState extends State<_MapboxMapLayer> {
static const _worldCenter = LatLng(0, 0);
static const _fallbackCenter = LatLng(16.0544, 108.2022);
final _viewportController = mbx.ViewportController();
late final mbx.CameraViewportState _initialViewport;
mbx.MapboxMap? _mapboxMap;
var _mapLoaded = false;
var _didInitialFlyIn = false;
var _cameraAnimationSequence = 0;
var _projectionVersion = 0;
Timer? _initialFlyInTimer;
LatLng? _lastFocusCenter;
List<_ProjectedMarker> _projectedPlaces = const [];
Offset? _projectedUserCoordinate;
@override
void initState() {
super.initState();
final initialFocus = widget.focusCenter;
_initialViewport = _cameraViewport(
initialFocus ?? _fallbackCenter,
zoom: initialFocus == null ? 11.2 : 15.0,
bearing: initialFocus == null ? 0 : -12,
pitch: initialFocus == null ? 0 : 60,
);
if (initialFocus != null) {
_didInitialFlyIn = true;
_lastFocusCenter = initialFocus;
}
}
@override
void didUpdateWidget(covariant _MapboxMapLayer oldWidget) {
super.didUpdateWidget(oldWidget);
@@ -355,12 +372,8 @@ class _MapboxMapLayerState extends State<_MapboxMapLayer> {
children: [
mbx.MapWidget(
styleUri: _mapboxStyleUri,
viewport: mbx.CameraViewportState(
center: _mapboxPoint(_worldCenter),
zoom: 0.48,
bearing: 0,
pitch: 0,
),
viewport: _initialViewport,
viewportController: _viewportController,
onMapCreated: _onMapCreated,
onStyleLoadedListener: (_) {
if (!kIsWeb) {
@@ -404,7 +417,7 @@ class _MapboxMapLayerState extends State<_MapboxMapLayer> {
@override
void dispose() {
_initialFlyInTimer?.cancel();
_viewportController.dispose();
super.dispose();
}
@@ -429,46 +442,65 @@ class _MapboxMapLayerState extends State<_MapboxMapLayer> {
}
_lastFocusCenter = focusCenter;
final camera = mbx.CameraOptions(
center: _mapboxPoint(focusCenter),
final camera = _cameraViewport(
focusCenter,
zoom: 15.0,
bearing: -12,
pitch: 60,
);
if (_didInitialFlyIn) {
_cameraAnimationSequence++;
unawaited(
mapboxMap.easeTo(camera, mbx.MapAnimationOptions(duration: 650)),
_viewportController.moveTo(
camera,
transition: const mbx.EasingViewportTransition(
duration: Duration(milliseconds: 650),
),
);
return;
}
_didInitialFlyIn = true;
final animationSequence = ++_cameraAnimationSequence;
unawaited(
mapboxMap.easeTo(
mbx.CameraOptions(
center: _mapboxPoint(_targetOrbitCenter(focusCenter)),
zoom: 0.72,
bearing: _targetOrbitBearing(focusCenter),
pitch: 0,
),
mbx.MapAnimationOptions(duration: 850),
_viewportController.moveTo(
_cameraViewport(
_targetOrbitCenter(focusCenter),
zoom: 0.72,
bearing: _targetOrbitBearing(focusCenter),
pitch: 0,
),
transition: const mbx.EasingViewportTransition(
duration: Duration(milliseconds: 850),
),
);
_initialFlyInTimer?.cancel();
_initialFlyInTimer = Timer(const Duration(milliseconds: 520), () {
Timer(const Duration(milliseconds: 520), () {
if (!mounted ||
animationSequence != _cameraAnimationSequence ||
_mapboxMap != mapboxMap) {
return;
}
unawaited(
mapboxMap.flyTo(camera, mbx.MapAnimationOptions(duration: 2800)),
_viewportController.moveTo(
camera,
transition: const mbx.FlyViewportTransition(
duration: Duration(milliseconds: 2800),
),
);
});
}
mbx.CameraViewportState _cameraViewport(
LatLng center, {
required double zoom,
required double bearing,
required double pitch,
}) {
return mbx.CameraViewportState(
center: _mapboxPoint(center),
zoom: zoom,
bearing: bearing,
pitch: pitch,
);
}
LatLng _targetOrbitCenter(LatLng target) {
final latitude = (target.latitude * 0.18).clamp(-18.0, 18.0);
return LatLng(latitude, target.longitude);
@@ -2396,12 +2428,7 @@ class _AddExperienceFlowState extends State<AddExperienceFlow> {
await _startRecording();
}
Future<List<PlaceRecommendation>> _loadNearbyPlaces() async {
final coordinate = widget.coordinate;
if (coordinate == null) {
return const [];
}
Future<List<PlaceRecommendation>> _loadNearbyPlaces(LatLng coordinate) async {
return context
.read<PlacesRepository>()
.fetchNearbyPlaces(
@@ -2490,9 +2517,29 @@ class _AddExperienceFlowState extends State<AddExperienceFlow> {
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
final controller = context.read<PlaceCubit>();
final locationState = context.select((PlaceCubit cubit) {
final state = cubit.state.placeState;
return (coordinate: state?.userCoordinate, status: state?.locationStatus);
});
final effectiveCoordinate = widget.coordinate ?? locationState.coordinate;
final coordinateResolving =
effectiveCoordinate == null &&
locationState.status == LocationResolutionStatus.resolving;
final hasTelegramAuth = context.select(
(PlaceCubit cubit) => cubit.state.placeState?.hasTelegramAuth ?? false,
);
if (_step == 1 &&
_nearbyPlacesFuture == null &&
effectiveCoordinate != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _step != 1 || _nearbyPlacesFuture != null) {
return;
}
setState(() {
_nearbyPlacesFuture = _loadNearbyPlaces(effectiveCoordinate);
});
});
}
final informationProgress = (_informationUnits / _minimumInformationUnits)
.clamp(0.0, 1.0);
final content = switch (_step) {
@@ -2512,19 +2559,24 @@ class _AddExperienceFlowState extends State<AddExperienceFlow> {
await _stopRecording();
}
setState(() {
_nearbyPlacesFuture = _loadNearbyPlaces();
_nearbyPlacesFuture = effectiveCoordinate == null
? null
: _loadNearbyPlaces(effectiveCoordinate);
_step = 1;
});
},
),
1 => _PlaceStep(
placesFuture: _nearbyPlacesFuture,
hasCoordinate: widget.coordinate != null,
hasCoordinate: effectiveCoordinate != null,
coordinateResolving: coordinateResolving,
radiusMeters: _nearbyPlaceRadiusMeters,
isSubmitting: _submitting,
onRetry: () {
setState(() {
_nearbyPlacesFuture = _loadNearbyPlaces();
_nearbyPlacesFuture = effectiveCoordinate == null
? null
: _loadNearbyPlaces(effectiveCoordinate);
});
},
onSelect: (place) async {
@@ -2626,6 +2678,7 @@ class _PlaceStep extends StatelessWidget {
const _PlaceStep({
required this.placesFuture,
required this.hasCoordinate,
required this.coordinateResolving,
required this.radiusMeters,
required this.isSubmitting,
required this.onRetry,
@@ -2634,6 +2687,7 @@ class _PlaceStep extends StatelessWidget {
final Future<List<PlaceRecommendation>>? placesFuture;
final bool hasCoordinate;
final bool coordinateResolving;
final int radiusMeters;
final bool isSubmitting;
final VoidCallback onRetry;
@@ -2658,11 +2712,17 @@ class _PlaceStep extends StatelessWidget {
const SizedBox(height: 16),
Expanded(
child: placesFuture == null
? _PlaceUnavailable(
icon: Icons.location_off_outlined,
message: 'Нет геопозиции',
tokens: tokens,
)
? coordinateResolving
? Center(
child: CircularProgressIndicator(
color: tokens.voiceAccent,
),
)
: _PlaceUnavailable(
icon: Icons.location_off_outlined,
message: 'Нет геопозиции',
tokens: tokens,
)
: FutureBuilder<List<PlaceRecommendation>>(
future: placesFuture,
builder: (context, snapshot) {