357 lines
10 KiB
Dart
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: [],
|
|
);
|