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