Files
flutter/lib/features/mapflow/application/place_cubit.dart
T
Ruslan Bakiev 318126d83a
Build and deploy Flutter Web / build (push) Successful in 2m41s
Add favorites search and enriched places UI
2026-06-12 21:09:15 +07:00

357 lines
10 KiB
Dart

import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:latlong2/latlong.dart';
import '../../../shared/location/current_location.dart';
import '../data/auth_repository.dart';
import '../data/places_repository.dart';
import '../data/voice_experiences_repository.dart';
import '../domain/place_models.dart';
const _unset = Object();
enum PlaceLoadStatus { loading, ready, failure }
class PlaceState {
const PlaceState({
required this.selectedTrait,
required this.places,
required this.selectedPlaceId,
required this.currentUser,
required this.hasTelegramAuth,
required this.userCoordinate,
required this.reviewDraft,
required this.searchQuery,
required this.searchMessage,
});
static final _distance = Distance();
final PlaceTrait? selectedTrait;
final List<PlaceRecommendation> places;
final String? selectedPlaceId;
final AppUser? currentUser;
final bool hasTelegramAuth;
final LatLng? userCoordinate;
final VoiceReviewDraft reviewDraft;
final String searchQuery;
final String searchMessage;
List<PlaceRecommendation> get recommendations {
final selected = selectedTrait;
final matchingPlaces = selected == null
? places
: places.where((place) => place.traits.contains(selected));
final coordinate = userCoordinate;
if (coordinate == null) {
return matchingPlaces.toList();
}
final ranked = [...matchingPlaces]
..sort((a, b) {
return _distance(
coordinate,
a.coordinate,
).compareTo(_distance(coordinate, b.coordinate));
});
return ranked;
}
PlaceRecommendation? get selectedPlace {
final visiblePlaces = recommendations;
for (final place in visiblePlaces) {
if (place.id == selectedPlaceId) {
return place;
}
}
return visiblePlaces.isEmpty ? null : visiblePlaces.first;
}
PlaceState copyWith({
Object? selectedTrait = _unset,
List<PlaceRecommendation>? places,
Object? selectedPlaceId = _unset,
Object? currentUser = _unset,
bool? hasTelegramAuth,
Object? userCoordinate = _unset,
VoiceReviewDraft? reviewDraft,
String? searchQuery,
String? searchMessage,
}) {
return PlaceState(
selectedTrait: identical(selectedTrait, _unset)
? this.selectedTrait
: selectedTrait as PlaceTrait?,
places: places ?? this.places,
selectedPlaceId: identical(selectedPlaceId, _unset)
? this.selectedPlaceId
: selectedPlaceId as String?,
currentUser: identical(currentUser, _unset)
? this.currentUser
: currentUser as AppUser?,
hasTelegramAuth: hasTelegramAuth ?? this.hasTelegramAuth,
userCoordinate: identical(userCoordinate, _unset)
? this.userCoordinate
: userCoordinate as LatLng?,
reviewDraft: reviewDraft ?? this.reviewDraft,
searchQuery: searchQuery ?? this.searchQuery,
searchMessage: searchMessage ?? this.searchMessage,
);
}
}
class PlaceViewState {
const PlaceViewState({
required this.status,
required this.placeState,
required this.errorMessage,
});
const PlaceViewState.loading()
: status = PlaceLoadStatus.loading,
placeState = null,
errorMessage = null;
const PlaceViewState.ready(PlaceState state)
: status = PlaceLoadStatus.ready,
placeState = state,
errorMessage = null;
const PlaceViewState.failure(String message)
: status = PlaceLoadStatus.failure,
placeState = null,
errorMessage = message;
final PlaceLoadStatus status;
final PlaceState? placeState;
final String? errorMessage;
}
class PlaceCubit extends Cubit<PlaceViewState> {
PlaceCubit({
required AuthRepository authRepository,
required PlacesRepository placesRepository,
required VoiceExperiencesRepository voiceExperiencesRepository,
required CurrentLocation location,
}) : _authRepository = authRepository,
_placesRepository = placesRepository,
_voiceExperiencesRepository = voiceExperiencesRepository,
_location = location,
super(const PlaceViewState.loading());
final AuthRepository _authRepository;
final PlacesRepository _placesRepository;
final VoiceExperiencesRepository _voiceExperiencesRepository;
final CurrentLocation _location;
Future<void> load() async {
emit(const PlaceViewState.loading());
if (!_authRepository.hasTelegramAuth) {
emit(PlaceViewState.ready(_emptyState(hasTelegramAuth: false)));
return;
}
final currentUser = await _authRepository.authenticateTelegram();
final userCoordinate = await _location.resolve();
final places = await _placesRepository.fetchPlaces();
emit(
PlaceViewState.ready(
PlaceState(
selectedTrait: null,
places: places,
selectedPlaceId: places.isEmpty ? null : places.first.id,
currentUser: currentUser,
hasTelegramAuth: _authRepository.hasTelegramAuth,
userCoordinate: userCoordinate,
reviewDraft: _emptyDraft,
searchQuery: '',
searchMessage: '',
),
),
);
}
void selectTrait(PlaceTrait trait) {
final value = _requireReady();
PlaceRecommendation? next;
for (final place in value.places) {
if (place.traits.contains(trait)) {
next = place;
break;
}
}
emit(
PlaceViewState.ready(
value.copyWith(selectedTrait: trait, selectedPlaceId: next?.id),
),
);
}
void clearTrait() {
final value = _requireReady();
final selectedPlaceId = value.places.isEmpty ? null : value.places.first.id;
emit(
PlaceViewState.ready(
value.copyWith(selectedTrait: null, selectedPlaceId: selectedPlaceId),
),
);
}
void selectPlace(String placeId) {
final value = _requireReady();
emit(PlaceViewState.ready(value.copyWith(selectedPlaceId: placeId)));
}
Future<void> searchPlaces(String query) async {
final value = _requireReady();
final normalizedQuery = query.trim();
if (normalizedQuery.isEmpty) {
await clearSearch();
return;
}
final result = await _placesRepository.searchPlaces(normalizedQuery);
emit(
PlaceViewState.ready(
value.copyWith(
places: result.places,
selectedPlaceId: result.places.isEmpty
? null
: result.places.first.id,
selectedTrait: null,
searchQuery: normalizedQuery,
searchMessage: result.message,
),
),
);
}
Future<void> clearSearch() async {
final value = _requireReady();
final places = await _placesRepository.fetchPlaces();
emit(
PlaceViewState.ready(
value.copyWith(
places: places,
selectedPlaceId: places.isEmpty ? null : places.first.id,
selectedTrait: null,
searchQuery: '',
searchMessage: '',
),
),
);
}
Future<void> toggleFavorite(PlaceRecommendation place) async {
final value = _requireReady();
final updated = place.isFavorite
? await _placesRepository.removeFavoritePlace(place.id)
: await _placesRepository.addFavoritePlace(place.id);
emit(PlaceViewState.ready(value.copyWith(places: _replacePlace(updated))));
}
void setReviewPlace(String placeName) {
final value = _requireReady();
emit(
PlaceViewState.ready(
value.copyWith(
reviewDraft: value.reviewDraft.copyWith(placeName: placeName),
),
),
);
}
void setReviewDuration(Duration duration) {
final value = _requireReady();
emit(
PlaceViewState.ready(
value.copyWith(
reviewDraft: value.reviewDraft.copyWith(duration: duration),
),
),
);
}
Future<void> publishReview({
required PlaceRecommendation place,
required String audioObjectKey,
required String audioContentBase64,
required String audioMimeType,
required bool addToFavorites,
required Map<String, dynamic> recordingPayload,
required List<String> promptHintsShown,
}) async {
final value = _requireReady();
if (!value.hasTelegramAuth) {
throw StateError('Открой через Telegram, чтобы оставить голос.');
}
final draft = value.reviewDraft;
await _voiceExperiencesRepository.createVoiceExperience(
googlePlaceId: place.googlePlaceId,
googleName: place.name,
coordinate: place.coordinate,
googlePrimaryType: place.googlePrimaryType,
googleTypes: place.googleTypes,
durationSeconds: draft.duration.inSeconds,
audioObjectKey: audioObjectKey,
audioContentBase64: audioContentBase64,
audioMimeType: audioMimeType,
addToFavorites: addToFavorites,
recordingPayload: recordingPayload,
promptHintsShown: promptHintsShown,
);
final places = await _placesRepository.fetchPlaces();
final selectedPlace = places.isEmpty ? null : places.first.id;
emit(
PlaceViewState.ready(
value.copyWith(
places: places,
selectedPlaceId: selectedPlace,
reviewDraft: _emptyDraft,
searchQuery: '',
searchMessage: '',
),
),
);
}
List<PlaceRecommendation> _replacePlace(PlaceRecommendation updated) {
final value = _requireReady();
return [
for (final place in value.places)
if (place.id == updated.id) updated else place,
];
}
PlaceState _requireReady() {
final value = state.placeState;
if (value == null) {
throw StateError('Place state is not ready.');
}
return value;
}
static PlaceState _emptyState({required bool hasTelegramAuth}) {
return PlaceState(
selectedTrait: null,
places: const [],
selectedPlaceId: null,
currentUser: null,
hasTelegramAuth: hasTelegramAuth,
userCoordinate: null,
reviewDraft: _emptyDraft,
searchQuery: '',
searchMessage: '',
);
}
}
const _emptyDraft = VoiceReviewDraft(
placeName: '',
duration: Duration.zero,
extractedTraits: {},
evidence: [],
);