Load map places from backend
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 3m26s

This commit is contained in:
Ruslan Bakiev
2026-05-08 15:54:15 +07:00
parent 4fb691135d
commit 238521b11b
10 changed files with 370 additions and 996 deletions

View File

@@ -1,209 +0,0 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:latlong2/latlong.dart';
import '../models/experience_models.dart';
final experienceControllerProvider =
NotifierProvider<ExperienceController, ExperienceState>(
ExperienceController.new,
);
class ExperienceState {
const ExperienceState({
required this.experiences,
required this.selectedExperienceId,
required this.filterEmotion,
required this.profile,
});
final List<PlaceExperience> experiences;
final String? selectedExperienceId;
final ExperienceEmotion? filterEmotion;
final ExperienceAuthor profile;
List<PlaceExperience> get visibleExperiences {
final emotion = filterEmotion;
if (emotion == null) {
return experiences;
}
return experiences.where((item) => item.emotion == emotion).toList();
}
PlaceExperience? get selectedExperience {
for (final experience in experiences) {
if (experience.id == selectedExperienceId) {
return experience;
}
}
return visibleExperiences.isEmpty ? null : visibleExperiences.first;
}
ExperienceState copyWith({
List<PlaceExperience>? experiences,
String? selectedExperienceId,
bool clearSelection = false,
ExperienceEmotion? filterEmotion,
bool clearFilter = false,
ExperienceAuthor? profile,
}) {
return ExperienceState(
experiences: experiences ?? this.experiences,
selectedExperienceId: clearSelection
? null
: selectedExperienceId ?? this.selectedExperienceId,
filterEmotion: clearFilter ? null : filterEmotion ?? this.filterEmotion,
profile: profile ?? this.profile,
);
}
}
class ExperienceController extends Notifier<ExperienceState> {
@override
ExperienceState build() {
final experiences = _seedExperiences();
return ExperienceState(
experiences: experiences,
selectedExperienceId: experiences.first.id,
filterEmotion: null,
profile: const ExperienceAuthor(
name: 'Руслан',
facets: [
ProfileFacet(name: 'темп', value: 'спокойно, без очередей'),
ProfileFacet(name: 'еда', value: 'яркое блюдо важнее кухни'),
ProfileFacet(
name: 'контекст',
value: 'работа днем, прогулки вечером',
),
],
),
);
}
void selectExperience(String id) {
state = state.copyWith(selectedExperienceId: id);
}
void setEmotionFilter(ExperienceEmotion? emotion) {
if (emotion == null) {
state = state.copyWith(clearFilter: true);
return;
}
final nextVisible = state.experiences.firstWhere(
(item) => item.emotion == emotion,
orElse: () => state.experiences.first,
);
state = state.copyWith(
filterEmotion: emotion,
selectedExperienceId: nextVisible.id,
);
}
void addExperience({
required String placeName,
required String dishName,
required ExperienceEmotion emotion,
required LatLng coordinate,
required String context,
}) {
final experience = PlaceExperience(
id: 'local-${DateTime.now().microsecondsSinceEpoch}',
placeName: placeName.trim().isEmpty ? 'Новое место' : placeName.trim(),
neighborhood: 'рядом',
coordinate: coordinate,
emotion: emotion,
intensity: 3,
context: context.trim().isEmpty ? 'личная заметка' : context.trim(),
dish: DishSignal(
name: dishName.trim().isEmpty ? 'блюдо' : dishName.trim(),
reason: 'стоит проверить лично',
texture: 'новый сигнал',
),
author: state.profile,
createdLabel: 'сейчас',
);
state = state.copyWith(
experiences: [experience, ...state.experiences],
selectedExperienceId: experience.id,
clearFilter: true,
);
}
List<PlaceExperience> _seedExperiences() {
const author = ExperienceAuthor(
name: 'Mira',
facets: [
ProfileFacet(name: 'темп', value: 'медленно'),
ProfileFacet(name: 'еда', value: 'текстура'),
ProfileFacet(name: 'настроение', value: 'тихое внимание'),
],
);
return const [
PlaceExperience(
id: 'secret-garden',
placeName: 'Secret Garden',
neighborhood: 'District 1',
coordinate: LatLng(10.7752, 106.7009),
emotion: ExperienceEmotion.comfort,
intensity: 4,
context: 'крыша, зелень, хороший разговор',
dish: DishSignal(
name: 'caramelized pork clay pot',
reason: 'мягко собирает вечер',
texture: 'густой соус, рис, тепло',
),
author: author,
createdLabel: 'вчера',
),
PlaceExperience(
id: 'banh-mi-huynh-hoa',
placeName: 'Banh Mi Huynh Hoa',
neighborhood: 'District 1',
coordinate: LatLng(10.7716, 106.6920),
emotion: ExperienceEmotion.energy,
intensity: 5,
context: 'быстро, плотно, без церемоний',
dish: DishSignal(
name: 'banh mi dac biet',
reason: 'если хочется прямого удара вкуса',
texture: 'хруст, паштет, травы',
),
author: author,
createdLabel: '3 дня назад',
),
PlaceExperience(
id: 'the-workshop',
placeName: 'The Workshop',
neighborhood: 'District 1',
coordinate: LatLng(10.7740, 106.7042),
emotion: ExperienceEmotion.focus,
intensity: 4,
context: 'ноутбук, кофе, два часа ясности',
dish: DishSignal(
name: 'egg coffee',
reason: 'сладкая пауза между задачами',
texture: 'крем, горечь, плотность',
),
author: author,
createdLabel: 'на неделе',
),
PlaceExperience(
id: 'oc-dao',
placeName: 'Oc Dao',
neighborhood: 'District 1',
coordinate: LatLng(10.7607, 106.6898),
emotion: ExperienceEmotion.curiosity,
intensity: 5,
context: 'пробовать руками, спорить, заказывать еще',
dish: DishSignal(
name: 'grilled scallops',
reason: 'блюдо ведет сильнее, чем место',
texture: 'дым, масло, арахис',
),
author: author,
createdLabel: 'месяц назад',
),
];
}
}

View File

@@ -1,11 +1,11 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:latlong2/latlong.dart';
import '../api/mapflow_api.dart';
import '../models/place_models.dart';
final placeControllerProvider = NotifierProvider<PlaceController, PlaceState>(
PlaceController.new,
);
final placeControllerProvider =
AsyncNotifierProvider<PlaceController, PlaceState>(PlaceController.new);
class PlaceState {
const PlaceState({
@@ -55,14 +55,16 @@ class PlaceState {
}
}
class PlaceController extends Notifier<PlaceState> {
class PlaceController extends AsyncNotifier<PlaceState> {
final _api = MapflowApi();
@override
PlaceState build() {
final places = _seedPlaces();
Future<PlaceState> build() async {
final places = await _api.fetchPlaces();
return PlaceState(
intent: UserIntent.exhale,
places: places,
selectedPlaceId: places.first.id,
selectedPlaceId: places.isEmpty ? null : places.first.id,
reviewDraft: const VoiceReviewDraft(
placeName: '',
duration: Duration.zero,
@@ -74,160 +76,72 @@ class PlaceController extends Notifier<PlaceState> {
}
void selectIntent(UserIntent intent) {
final value = state.requireValue;
PlaceRecommendation? next;
for (final place in state.places) {
for (final place in value.places) {
if (place.traits.intersection(intent.traits).isNotEmpty) {
next = place;
break;
}
}
state = state.copyWith(intent: intent, selectedPlaceId: next?.id);
state = AsyncData(
value.copyWith(intent: intent, selectedPlaceId: next?.id),
);
}
void selectPlace(String placeId) {
state = state.copyWith(selectedPlaceId: placeId);
final value = state.requireValue;
state = AsyncData(value.copyWith(selectedPlaceId: placeId));
}
void setReviewPlace(String placeName) {
state = state.copyWith(
reviewDraft: state.reviewDraft.copyWith(placeName: placeName),
final value = state.requireValue;
state = AsyncData(
value.copyWith(
reviewDraft: value.reviewDraft.copyWith(placeName: placeName),
),
);
}
void setReviewDuration(Duration duration) {
state = state.copyWith(
reviewDraft: state.reviewDraft.copyWith(duration: duration),
);
}
void analyzeVoiceReview() {
final placeName = state.reviewDraft.placeName.trim().isEmpty
? 'Новое место'
: state.reviewDraft.placeName.trim();
state = state.copyWith(
reviewDraft: state.reviewDraft.copyWith(
placeName: placeName,
duration: state.reviewDraft.duration.inSeconds < 30
? const Duration(seconds: 36)
: state.reviewDraft.duration,
extractedTraits: {
PlaceTrait.cozy,
PlaceTrait.private,
PlaceTrait.beautiful,
PlaceTrait.calm,
},
suggestedIntents: {UserIntent.exhale, UserIntent.date},
evidence: [
'можно нормально поговорить',
'место мягкое, не давит',
'туда хочется привести человека вечером',
],
final value = state.requireValue;
state = AsyncData(
value.copyWith(
reviewDraft: value.reviewDraft.copyWith(duration: duration),
),
);
}
void publishReview({LatLng? coordinate}) {
final draft = state.reviewDraft;
final place = PlaceRecommendation(
id: 'local-${DateTime.now().microsecondsSinceEpoch}',
name: draft.placeName.trim().isEmpty ? 'Новое место' : draft.placeName,
area: 'добавлено голосом',
photoUrls: const [
'https://images.unsplash.com/photo-1554118811-1e0d58224f24?auto=format&fit=crop&w=600&q=80',
'https://images.unsplash.com/photo-1514933651103-005eec06c04b?auto=format&fit=crop&w=600&q=80',
'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?auto=format&fit=crop&w=600&q=80',
],
coordinate: coordinate ?? const LatLng(10.7729, 106.7004),
traits: draft.extractedTraits.isEmpty
? {PlaceTrait.cozy, PlaceTrait.calm}
: draft.extractedTraits,
Future<void> publishReview({LatLng? coordinate}) async {
final value = state.requireValue;
final draft = value.reviewDraft;
final placeName = draft.placeName.trim().isEmpty
? 'Место на карте'
: draft.placeName.trim();
final point = coordinate ?? const LatLng(10.7729, 106.7004);
await _api.createVoiceExperience(
googlePlaceId: 'manual-${point.latitude}-${point.longitude}-$placeName',
googleName: placeName,
coordinate: point,
durationSeconds: draft.duration.inSeconds,
audioObjectKey: 'web-recording-${DateTime.now().microsecondsSinceEpoch}',
);
state = state.copyWith(
places: [place, ...state.places],
selectedPlaceId: place.id,
reviewDraft: const VoiceReviewDraft(
placeName: '',
duration: Duration.zero,
extractedTraits: {},
suggestedIntents: {},
evidence: [],
final places = await _api.fetchPlaces();
final selectedPlace = places.isEmpty ? null : places.first.id;
state = AsyncData(
value.copyWith(
places: places,
selectedPlaceId: selectedPlace,
reviewDraft: const VoiceReviewDraft(
placeName: '',
duration: Duration.zero,
extractedTraits: {},
suggestedIntents: {},
evidence: [],
),
),
);
}
List<PlaceRecommendation> _seedPlaces() {
return const [
PlaceRecommendation(
id: 'secret-garden',
name: 'Secret Garden',
area: 'District 1',
photoUrls: [
'https://images.unsplash.com/photo-1552566626-52f8b828add9?auto=format&fit=crop&w=600&q=80',
'https://images.unsplash.com/photo-1559339352-11d035aa65de?auto=format&fit=crop&w=600&q=80',
'https://images.unsplash.com/photo-1521017432531-fbd92d768814?auto=format&fit=crop&w=600&q=80',
],
coordinate: LatLng(10.7752, 106.7009),
traits: {
PlaceTrait.calm,
PlaceTrait.cozy,
PlaceTrait.private,
PlaceTrait.beautiful,
PlaceTrait.social,
},
),
PlaceRecommendation(
id: 'workshop',
name: 'The Workshop',
area: 'District 1',
photoUrls: [
'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&w=600&q=80',
'https://images.unsplash.com/photo-1501339847302-ac426a4a7cbb?auto=format&fit=crop&w=600&q=80',
'https://images.unsplash.com/photo-1511920170033-f8396924c348?auto=format&fit=crop&w=600&q=80',
],
coordinate: LatLng(10.7740, 106.7042),
traits: {
PlaceTrait.focused,
PlaceTrait.calm,
PlaceTrait.neutral,
PlaceTrait.solo,
},
),
PlaceRecommendation(
id: 'oc-dao',
name: 'Oc Dao',
area: 'District 1',
photoUrls: [
'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?auto=format&fit=crop&w=600&q=80',
'https://images.unsplash.com/photo-1544025162-d76694265947?auto=format&fit=crop&w=600&q=80',
'https://images.unsplash.com/photo-1551218808-94e220e084d2?auto=format&fit=crop&w=600&q=80',
],
coordinate: LatLng(10.7607, 106.6898),
traits: {
PlaceTrait.alive,
PlaceTrait.open,
PlaceTrait.social,
PlaceTrait.unusual,
},
),
PlaceRecommendation(
id: 'l-usine',
name: 'L\'Usine',
area: 'Dong Khoi',
photoUrls: [
'https://images.unsplash.com/photo-1514933651103-005eec06c04b?auto=format&fit=crop&w=600&q=80',
'https://images.unsplash.com/photo-1550966871-3ed3cdb5ed0c?auto=format&fit=crop&w=600&q=80',
'https://images.unsplash.com/photo-1551632436-cbf8dd35adfa?auto=format&fit=crop&w=600&q=80',
],
coordinate: LatLng(10.7755, 106.7038),
traits: {
PlaceTrait.status,
PlaceTrait.beautiful,
PlaceTrait.private,
PlaceTrait.clear,
},
),
];
}
}