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

141
lib/api/mapflow_api.dart Normal file
View File

@@ -0,0 +1,141 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:latlong2/latlong.dart';
import '../models/place_models.dart';
class MapflowApi {
MapflowApi({
http.Client? client,
String endpoint = const String.fromEnvironment(
'API_BASE_URL',
defaultValue: '/graphql',
),
}) : _client = client ?? http.Client(),
_endpoint = Uri.base.resolve(endpoint);
final http.Client _client;
final Uri _endpoint;
Future<List<PlaceRecommendation>> fetchPlaces() async {
final data = await _graphql('''
query Places {
places {
id
googlePlaceId
name
latitude
longitude
experiences {
id
status
analysis
createdAt
}
}
}
''');
final places = data['places'] as List<dynamic>;
return places.map((item) {
final place = item as Map<String, dynamic>;
return PlaceRecommendation(
id: place['id'] as String,
name: place['name'] as String,
area: '',
photoUrls: const [],
coordinate: LatLng(
(place['latitude'] as num).toDouble(),
(place['longitude'] as num).toDouble(),
),
traits: _traitsFromExperiences(place['experiences'] as List<dynamic>),
);
}).toList();
}
Future<void> createVoiceExperience({
required String googlePlaceId,
required String googleName,
required LatLng coordinate,
required int durationSeconds,
required String audioObjectKey,
}) async {
await _graphql(
'''
mutation CreateVoiceExperience(\$input: CreateVoiceExperienceInput!) {
createVoiceExperience(input: \$input) {
id
}
}
''',
variables: {
'input': {
'googlePlaceId': googlePlaceId,
'googleName': googleName,
'latitude': coordinate.latitude,
'longitude': coordinate.longitude,
'durationSeconds': durationSeconds,
'audioObjectKey': audioObjectKey,
},
},
);
}
Future<Map<String, dynamic>> _graphql(
String query, {
Map<String, dynamic>? variables,
}) async {
final response = await _client.post(
_endpoint,
headers: const {'content-type': 'application/json'},
body: jsonEncode({'query': query, 'variables': variables ?? {}}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw StateError('GraphQL request failed with ${response.statusCode}.');
}
final payload = jsonDecode(response.body) as Map<String, dynamic>;
final errors = payload['errors'];
if (errors is List && errors.isNotEmpty) {
throw StateError(jsonEncode(errors));
}
return payload['data'] as Map<String, dynamic>;
}
Set<PlaceTrait> _traitsFromExperiences(List<dynamic> experiences) {
final traits = <PlaceTrait>{};
for (final item in experiences) {
final experience = item as Map<String, dynamic>;
final analysis = experience['analysis'];
if (analysis is! Map<String, dynamic>) {
continue;
}
final tags = analysis['tags'];
if (tags is! List) {
continue;
}
for (final tag in tags) {
final trait = _traitByName(tag.toString());
if (trait != null) {
traits.add(trait);
}
}
}
return traits;
}
PlaceTrait? _traitByName(String name) {
for (final trait in PlaceTrait.values) {
if (trait.name == name) {
return trait;
}
}
return null;
}
}

View File

@@ -1,78 +0,0 @@
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
enum ExperienceEmotion { comfort, energy, curiosity, tenderness, focus }
extension ExperienceEmotionText on ExperienceEmotion {
String get label {
return switch (this) {
ExperienceEmotion.comfort => 'уют',
ExperienceEmotion.energy => 'энергия',
ExperienceEmotion.curiosity => 'любопытство',
ExperienceEmotion.tenderness => 'нежность',
ExperienceEmotion.focus => 'собранность',
};
}
IconData get icon {
return switch (this) {
ExperienceEmotion.comfort => Icons.weekend_outlined,
ExperienceEmotion.energy => Icons.bolt_outlined,
ExperienceEmotion.curiosity => Icons.explore_outlined,
ExperienceEmotion.tenderness => Icons.spa_outlined,
ExperienceEmotion.focus => Icons.center_focus_strong_outlined,
};
}
}
class DishSignal {
const DishSignal({
required this.name,
required this.reason,
required this.texture,
});
final String name;
final String reason;
final String texture;
}
class ProfileFacet {
const ProfileFacet({required this.name, required this.value});
final String name;
final String value;
}
class ExperienceAuthor {
const ExperienceAuthor({required this.name, required this.facets});
final String name;
final List<ProfileFacet> facets;
}
class PlaceExperience {
const PlaceExperience({
required this.id,
required this.placeName,
required this.neighborhood,
required this.coordinate,
required this.emotion,
required this.intensity,
required this.context,
required this.dish,
required this.author,
required this.createdLabel,
});
final String id;
final String placeName;
final String neighborhood;
final LatLng coordinate;
final ExperienceEmotion emotion;
final int intensity;
final String context;
final DishSignal dish;
final ExperienceAuthor author;
final String createdLabel;
}

View File

@@ -1,495 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:latlong2/latlong.dart';
import '../models/experience_models.dart';
import '../state/experience_controller.dart';
class ExperienceMapScreen extends ConsumerWidget {
const ExperienceMapScreen({super.key});
static const _initialCenter = LatLng(10.7718, 106.6982);
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(experienceControllerProvider);
final selected = state.selectedExperience;
return Scaffold(
body: LayoutBuilder(
builder: (context, constraints) {
final wide = constraints.maxWidth >= 780;
return Stack(
children: [
FlutterMap(
options: MapOptions(
initialCenter: selected?.coordinate ?? _initialCenter,
initialZoom: 14.2,
minZoom: 3,
maxZoom: 18,
onLongPress: (_, point) =>
_showShareSheet(context, ref, point),
),
children: [
TileLayer(
urlTemplate:
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.mapflow.app',
),
MarkerLayer(
markers: [
for (final experience in state.visibleExperiences)
Marker(
width: 54,
height: 54,
point: experience.coordinate,
child: _ExperienceMarker(
experience: experience,
selected: selected?.id == experience.id,
onTap: () => ref
.read(experienceControllerProvider.notifier)
.selectExperience(experience.id),
),
),
],
),
const RichAttributionWidget(
attributions: [
TextSourceAttribution('OpenStreetMap contributors'),
],
),
],
),
SafeArea(
child: Padding(
padding: const EdgeInsets.all(14),
child: Align(
alignment: Alignment.topLeft,
child: _TopPanel(state: state),
),
),
),
if (wide)
SafeArea(
child: Align(
alignment: Alignment.centerRight,
child: Padding(
padding: const EdgeInsets.all(18),
child: SizedBox(
width: 330,
child: _ExperiencePanel(
experience: selected,
onShare: () => _showShareSheet(
context,
ref,
selected?.coordinate ?? _initialCenter,
),
),
),
),
),
)
else
Align(
alignment: Alignment.bottomCenter,
child: _BottomExperiencePanel(
experience: selected,
onShare: () => _showShareSheet(
context,
ref,
selected?.coordinate ?? _initialCenter,
),
),
),
],
);
},
),
);
}
void _showShareSheet(BuildContext context, WidgetRef ref, LatLng coordinate) {
final placeController = TextEditingController();
final dishController = TextEditingController();
final contextController = TextEditingController();
var emotion = ExperienceEmotion.comfort;
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
isScrollControlled: true,
builder: (sheetContext) {
return StatefulBuilder(
builder: (context, setState) {
return Padding(
padding: EdgeInsets.fromLTRB(
18,
8,
18,
MediaQuery.viewInsetsOf(context).bottom + 18,
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Поделиться опытом',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 14),
TextField(
controller: placeController,
decoration: const InputDecoration(labelText: 'Место'),
),
const SizedBox(height: 10),
TextField(
controller: dishController,
decoration: const InputDecoration(labelText: 'Блюдо'),
),
const SizedBox(height: 10),
TextField(
controller: contextController,
minLines: 2,
maxLines: 3,
decoration: const InputDecoration(labelText: 'Контекст'),
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final item in ExperienceEmotion.values)
ChoiceChip(
label: Text(item.label),
selected: emotion == item,
onSelected: (_) => setState(() => emotion = item),
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () {
ref
.read(experienceControllerProvider.notifier)
.addExperience(
placeName: placeController.text,
dishName: dishController.text,
emotion: emotion,
coordinate: coordinate,
context: contextController.text,
);
Navigator.of(sheetContext).pop();
},
icon: const Icon(Icons.add_location_alt_outlined),
label: const Text('Сохранить'),
),
),
],
),
),
);
},
);
},
).whenComplete(() {
placeController.dispose();
dishController.dispose();
contextController.dispose();
});
}
}
class _TopPanel extends ConsumerWidget {
const _TopPanel({required this.state});
final ExperienceState state;
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = ref.read(experienceControllerProvider.notifier);
return Material(
color: const Color(0xFFFFFBF5),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(12),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.map_outlined, size: 22),
const SizedBox(width: 8),
Text(
'MapFlow',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
),
],
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
FilterChip(
label: const Text('все'),
selected: state.filterEmotion == null,
onSelected: (_) => controller.setEmotionFilter(null),
),
for (final emotion in ExperienceEmotion.values)
FilterChip(
avatar: Icon(emotion.icon, size: 17),
label: Text(emotion.label),
selected: state.filterEmotion == emotion,
onSelected: (_) => controller.setEmotionFilter(emotion),
),
],
),
],
),
),
),
);
}
}
class _BottomExperiencePanel extends StatelessWidget {
const _BottomExperiencePanel({
required this.experience,
required this.onShare,
});
final PlaceExperience? experience;
final VoidCallback onShare;
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: _ExperiencePanel(experience: experience, onShare: onShare),
),
);
}
}
class _ExperiencePanel extends StatelessWidget {
const _ExperiencePanel({required this.experience, required this.onShare});
final PlaceExperience? experience;
final VoidCallback onShare;
@override
Widget build(BuildContext context) {
final item = experience;
return Material(
color: const Color(0xFFFFFBF5),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.all(14),
child: item == null
? FilledButton.icon(
onPressed: onShare,
icon: const Icon(Icons.add_location_alt_outlined),
label: const Text('Поделиться'),
)
: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_EmotionBadge(emotion: item.emotion),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.placeName,
style: Theme.of(context).textTheme.titleLarge
?.copyWith(fontWeight: FontWeight.w900),
),
Text(item.neighborhood),
],
),
),
],
),
const SizedBox(height: 12),
Text(
item.dish.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
Text(item.dish.reason),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_TinyPill(text: item.dish.texture),
_TinyPill(text: item.context),
_TinyPill(text: item.createdLabel),
],
),
const SizedBox(height: 14),
_AuthorBlock(author: item.author),
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: onShare,
icon: const Icon(Icons.add_location_alt_outlined),
label: const Text('Поделиться рядом'),
),
),
],
),
),
);
}
}
class _ExperienceMarker extends StatelessWidget {
const _ExperienceMarker({
required this.experience,
required this.selected,
required this.onTap,
});
final PlaceExperience experience;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final color = _emotionColor(experience.emotion);
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
decoration: BoxDecoration(
color: selected ? color : Colors.white,
shape: BoxShape.circle,
border: Border.all(color: color, width: selected ? 3 : 2),
boxShadow: const [
BoxShadow(
color: Color(0x33000000),
blurRadius: 14,
offset: Offset(0, 8),
),
],
),
child: Icon(
experience.emotion.icon,
color: selected ? Colors.white : color,
size: 24,
),
),
);
}
}
class _EmotionBadge extends StatelessWidget {
const _EmotionBadge({required this.emotion});
final ExperienceEmotion emotion;
@override
Widget build(BuildContext context) {
final color = _emotionColor(emotion);
return Container(
width: 46,
height: 46,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Icon(emotion.icon, color: color),
);
}
}
class _AuthorBlock extends StatelessWidget {
const _AuthorBlock({required this.author});
final ExperienceAuthor author;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
author.name,
style: Theme.of(
context,
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final facet in author.facets)
_TinyPill(text: '${facet.name}: ${facet.value}'),
],
),
],
);
}
}
class _TinyPill extends StatelessWidget {
const _TinyPill({required this.text});
final String text;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFFF0E8DA),
borderRadius: BorderRadius.circular(999),
),
child: Text(
text,
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700),
),
);
}
}
Color _emotionColor(ExperienceEmotion emotion) {
return switch (emotion) {
ExperienceEmotion.comfort => const Color(0xFF0F766E),
ExperienceEmotion.energy => const Color(0xFFE11D48),
ExperienceEmotion.curiosity => const Color(0xFF7C3AED),
ExperienceEmotion.tenderness => const Color(0xFFDB2777),
ExperienceEmotion.focus => const Color(0xFF2563EB),
};
}

View File

@@ -11,11 +11,27 @@ import '../state/place_controller.dart';
class MapflowShell extends ConsumerWidget { class MapflowShell extends ConsumerWidget {
const MapflowShell({super.key}); const MapflowShell({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asyncState = ref.watch(placeControllerProvider);
return asyncState.when(
data: (state) => _MapContent(state: state),
loading: () => const _MapLoading(),
error: (error, _) => _MapError(message: error.toString()),
);
}
}
class _MapContent extends ConsumerWidget {
const _MapContent({required this.state});
static const _center = LatLng(10.7718, 106.6982); static const _center = LatLng(10.7718, 106.6982);
final PlaceState state;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(placeControllerProvider);
final selected = state.selectedPlace; final selected = state.selectedPlace;
return Scaffold( return Scaffold(
@@ -101,6 +117,76 @@ class MapflowShell extends ConsumerWidget {
} }
} }
class _MapLoading extends StatelessWidget {
const _MapLoading();
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
FlutterMap(
options: const MapOptions(
initialCenter: LatLng(10.7718, 106.6982),
initialZoom: 14.2,
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.mapflow.app',
),
],
),
const Center(child: CircularProgressIndicator()),
],
),
);
}
}
class _MapError extends StatelessWidget {
const _MapError({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
FlutterMap(
options: const MapOptions(
initialCenter: LatLng(10.7718, 106.6982),
initialZoom: 14.2,
),
children: [
TileLayer(
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
userAgentPackageName: 'com.mapflow.app',
),
],
),
SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(12),
color: const Color(0xFFFFFBF5),
child: Text(
message,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
),
),
],
),
);
}
}
class _IntentBar extends ConsumerWidget { class _IntentBar extends ConsumerWidget {
const _IntentBar({required this.intent}); const _IntentBar({required this.intent});
@@ -173,6 +259,10 @@ class _PlaceCarousel extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (places.isEmpty) {
return const SizedBox.shrink();
}
return SizedBox( return SizedBox(
height: 172, height: 172,
child: ListView.separated( child: ListView.separated(
@@ -216,19 +306,25 @@ class _PlacePhotoCard extends StatelessWidget {
child: Stack( child: Stack(
fit: StackFit.expand, fit: StackFit.expand,
children: [ children: [
PageView.builder( if (place.photoUrls.isEmpty)
itemCount: place.photoUrls.length, const ColoredBox(
itemBuilder: (context, index) { color: Color(0xFF0F766E),
return Image.network( child: Icon(Icons.place_outlined, color: Colors.white),
place.photoUrls[index], )
fit: BoxFit.cover, else
errorBuilder: (_, _, _) => Container( PageView.builder(
color: const Color(0xFFE0D8CA), itemCount: place.photoUrls.length,
child: const Icon(Icons.place_outlined), itemBuilder: (context, index) {
), return Image.network(
); place.photoUrls[index],
}, fit: BoxFit.cover,
), errorBuilder: (_, _, _) => Container(
color: const Color(0xFFE0D8CA),
child: const Icon(Icons.place_outlined),
),
);
},
),
const DecoratedBox( const DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
@@ -273,37 +369,23 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
static const _minimumVoiceSeconds = 30; static const _minimumVoiceSeconds = 30;
Timer? _timer; Timer? _timer;
late final TextEditingController _placeNameController;
var _step = 0; var _step = 0;
var _seconds = 0; var _seconds = 0;
var _recording = false; var _recording = false;
_GooglePlaceStub? _selectedGooglePlace; var _submitting = false;
PlaceRecommendation? _selectedPlace;
static const _nearbyPlaces = [ @override
_GooglePlaceStub( void initState() {
name: 'Secret Garden', super.initState();
area: '120 m', _placeNameController = TextEditingController();
coordinate: LatLng(10.7752, 106.7009), }
),
_GooglePlaceStub(
name: 'The Workshop',
area: '210 m',
coordinate: LatLng(10.7740, 106.7042),
),
_GooglePlaceStub(
name: 'L\'Usine',
area: '360 m',
coordinate: LatLng(10.7755, 106.7038),
),
_GooglePlaceStub(
name: 'Oc Dao',
area: '780 m',
coordinate: LatLng(10.7607, 106.6898),
),
];
@override @override
void dispose() { void dispose() {
_timer?.cancel(); _timer?.cancel();
_placeNameController.dispose();
super.dispose(); super.dispose();
} }
@@ -324,6 +406,7 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final controller = ref.read(placeControllerProvider.notifier); final controller = ref.read(placeControllerProvider.notifier);
final places = ref.watch(placeControllerProvider).value?.places ?? [];
final time = final time =
'${(_seconds ~/ 60).toString().padLeft(2, '0')}:' '${(_seconds ~/ 60).toString().padLeft(2, '0')}:'
'${(_seconds % 60).toString().padLeft(2, '0')}'; '${(_seconds % 60).toString().padLeft(2, '0')}';
@@ -331,29 +414,38 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
final content = switch (_step) { final content = switch (_step) {
0 => _IntroStep(onNext: () => setState(() => _step = 1)), 0 => _IntroStep(onNext: () => setState(() => _step = 1)),
1 => _PlaceStep( 1 => _PlaceStep(
places: _nearbyPlaces, places: places,
controller: _placeNameController,
onSelect: (place) { onSelect: (place) {
setState(() { setState(() {
_selectedGooglePlace = place; _selectedPlace = place;
_placeNameController.text = place.name;
_step = 2; _step = 2;
}); });
controller.setReviewPlace(place.name); controller.setReviewPlace(place.name);
}, },
onManualNext: () {
controller.setReviewPlace(_placeNameController.text);
setState(() => _step = 2);
},
), ),
_ => _VoiceStep( _ => _VoiceStep(
place: _selectedGooglePlace, placeName: _placeNameController.text,
seconds: _seconds, seconds: _seconds,
minimumSeconds: _minimumVoiceSeconds, minimumSeconds: _minimumVoiceSeconds,
time: time, time: time,
isRecording: _recording, isRecording: _recording,
isSubmitting: _submitting,
canContinue: _seconds >= _minimumVoiceSeconds, canContinue: _seconds >= _minimumVoiceSeconds,
onToggleRecording: _toggleRecording, onToggleRecording: _toggleRecording,
onNext: () { onNext: () async {
final coordinate = setState(() => _submitting = true);
_selectedGooglePlace?.coordinate ?? widget.coordinate; final coordinate = _selectedPlace?.coordinate ?? widget.coordinate;
controller.setReviewPlace(_selectedGooglePlace?.name ?? ''); controller.setReviewPlace(_placeNameController.text);
controller.analyzeVoiceReview(); await controller.publishReview(coordinate: coordinate);
controller.publishReview(coordinate: coordinate); if (!context.mounted) {
return;
}
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
), ),
@@ -430,10 +522,17 @@ class _IntroStep extends StatelessWidget {
} }
class _PlaceStep extends StatelessWidget { class _PlaceStep extends StatelessWidget {
const _PlaceStep({required this.places, required this.onSelect}); const _PlaceStep({
required this.places,
required this.controller,
required this.onSelect,
required this.onManualNext,
});
final List<_GooglePlaceStub> places; final List<PlaceRecommendation> places;
final ValueChanged<_GooglePlaceStub> onSelect; final TextEditingController controller;
final ValueChanged<PlaceRecommendation> onSelect;
final VoidCallback onManualNext;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -442,15 +541,19 @@ class _PlaceStep extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Выбери место рядом', 'Место',
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
letterSpacing: 0, letterSpacing: 0,
), ),
), ),
const SizedBox(height: 6),
const Text('Покажем Google Places по твоей геолокации.'),
const SizedBox(height: 16), const SizedBox(height: 16),
TextField(
controller: controller,
decoration: const InputDecoration(hintText: 'Название места'),
textInputAction: TextInputAction.done,
),
const SizedBox(height: 12),
Expanded( Expanded(
child: ListView.separated( child: ListView.separated(
itemCount: places.length, itemCount: places.length,
@@ -472,34 +575,36 @@ class _PlaceStep extends StatelessWidget {
place.name, place.name,
style: const TextStyle(fontWeight: FontWeight.w800), style: const TextStyle(fontWeight: FontWeight.w800),
), ),
trailing: Text(place.area),
); );
}, },
), ),
), ),
], ],
), ),
action: FilledButton(onPressed: onManualNext, child: const Text('Далее')),
); );
} }
} }
class _VoiceStep extends StatelessWidget { class _VoiceStep extends StatelessWidget {
const _VoiceStep({ const _VoiceStep({
required this.place, required this.placeName,
required this.seconds, required this.seconds,
required this.minimumSeconds, required this.minimumSeconds,
required this.time, required this.time,
required this.isRecording, required this.isRecording,
required this.isSubmitting,
required this.canContinue, required this.canContinue,
required this.onToggleRecording, required this.onToggleRecording,
required this.onNext, required this.onNext,
}); });
final _GooglePlaceStub? place; final String placeName;
final int seconds; final int seconds;
final int minimumSeconds; final int minimumSeconds;
final String time; final String time;
final bool isRecording; final bool isRecording;
final bool isSubmitting;
final bool canContinue; final bool canContinue;
final VoidCallback onToggleRecording; final VoidCallback onToggleRecording;
final VoidCallback onNext; final VoidCallback onNext;
@@ -511,7 +616,7 @@ class _VoiceStep extends StatelessWidget {
children: [ children: [
const Spacer(), const Spacer(),
Text( Text(
place?.name ?? 'Место', placeName.trim().isEmpty ? 'Место' : placeName.trim(),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: Theme.of( style: Theme.of(
context, context,
@@ -524,7 +629,7 @@ class _VoiceStep extends StatelessWidget {
width: 132, width: 132,
height: 132, height: 132,
child: FilledButton( child: FilledButton(
onPressed: onToggleRecording, onPressed: isSubmitting ? null : onToggleRecording,
style: FilledButton.styleFrom(shape: const CircleBorder()), style: FilledButton.styleFrom(shape: const CircleBorder()),
child: Icon(isRecording ? Icons.stop : Icons.mic, size: 54), child: Icon(isRecording ? Icons.stop : Icons.mic, size: 54),
), ),
@@ -544,8 +649,8 @@ class _VoiceStep extends StatelessWidget {
], ],
), ),
action: FilledButton( action: FilledButton(
onPressed: canContinue ? onNext : null, onPressed: canContinue && !isSubmitting ? onNext : null,
child: const Text('Далее'), child: Text(isSubmitting ? 'Отправляем' : 'Далее'),
), ),
); );
} }
@@ -614,15 +719,3 @@ class _StoryProgress extends StatelessWidget {
); );
} }
} }
class _GooglePlaceStub {
const _GooglePlaceStub({
required this.name,
required this.area,
required this.coordinate,
});
final String name;
final String area;
final LatLng coordinate;
}

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:flutter_riverpod/flutter_riverpod.dart';
import 'package:latlong2/latlong.dart'; import 'package:latlong2/latlong.dart';
import '../api/mapflow_api.dart';
import '../models/place_models.dart'; import '../models/place_models.dart';
final placeControllerProvider = NotifierProvider<PlaceController, PlaceState>( final placeControllerProvider =
PlaceController.new, AsyncNotifierProvider<PlaceController, PlaceState>(PlaceController.new);
);
class PlaceState { class PlaceState {
const PlaceState({ const PlaceState({
@@ -55,14 +55,16 @@ class PlaceState {
} }
} }
class PlaceController extends Notifier<PlaceState> { class PlaceController extends AsyncNotifier<PlaceState> {
final _api = MapflowApi();
@override @override
PlaceState build() { Future<PlaceState> build() async {
final places = _seedPlaces(); final places = await _api.fetchPlaces();
return PlaceState( return PlaceState(
intent: UserIntent.exhale, intent: UserIntent.exhale,
places: places, places: places,
selectedPlaceId: places.first.id, selectedPlaceId: places.isEmpty ? null : places.first.id,
reviewDraft: const VoiceReviewDraft( reviewDraft: const VoiceReviewDraft(
placeName: '', placeName: '',
duration: Duration.zero, duration: Duration.zero,
@@ -74,160 +76,72 @@ class PlaceController extends Notifier<PlaceState> {
} }
void selectIntent(UserIntent intent) { void selectIntent(UserIntent intent) {
final value = state.requireValue;
PlaceRecommendation? next; PlaceRecommendation? next;
for (final place in state.places) { for (final place in value.places) {
if (place.traits.intersection(intent.traits).isNotEmpty) { if (place.traits.intersection(intent.traits).isNotEmpty) {
next = place; next = place;
break; break;
} }
} }
state = state.copyWith(intent: intent, selectedPlaceId: next?.id); state = AsyncData(
value.copyWith(intent: intent, selectedPlaceId: next?.id),
);
} }
void selectPlace(String placeId) { void selectPlace(String placeId) {
state = state.copyWith(selectedPlaceId: placeId); final value = state.requireValue;
state = AsyncData(value.copyWith(selectedPlaceId: placeId));
} }
void setReviewPlace(String placeName) { void setReviewPlace(String placeName) {
state = state.copyWith( final value = state.requireValue;
reviewDraft: state.reviewDraft.copyWith(placeName: placeName), state = AsyncData(
value.copyWith(
reviewDraft: value.reviewDraft.copyWith(placeName: placeName),
),
); );
} }
void setReviewDuration(Duration duration) { void setReviewDuration(Duration duration) {
state = state.copyWith( final value = state.requireValue;
reviewDraft: state.reviewDraft.copyWith(duration: duration), state = AsyncData(
); value.copyWith(
} reviewDraft: value.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: [
'можно нормально поговорить',
'место мягкое, не давит',
'туда хочется привести человека вечером',
],
), ),
); );
} }
void publishReview({LatLng? coordinate}) { Future<void> publishReview({LatLng? coordinate}) async {
final draft = state.reviewDraft; final value = state.requireValue;
final place = PlaceRecommendation( final draft = value.reviewDraft;
id: 'local-${DateTime.now().microsecondsSinceEpoch}', final placeName = draft.placeName.trim().isEmpty
name: draft.placeName.trim().isEmpty ? 'Новое место' : draft.placeName, ? 'Место на карте'
area: 'добавлено голосом', : draft.placeName.trim();
photoUrls: const [ final point = coordinate ?? const LatLng(10.7729, 106.7004);
'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', await _api.createVoiceExperience(
'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?auto=format&fit=crop&w=600&q=80', googlePlaceId: 'manual-${point.latitude}-${point.longitude}-$placeName',
], googleName: placeName,
coordinate: coordinate ?? const LatLng(10.7729, 106.7004), coordinate: point,
traits: draft.extractedTraits.isEmpty durationSeconds: draft.duration.inSeconds,
? {PlaceTrait.cozy, PlaceTrait.calm} audioObjectKey: 'web-recording-${DateTime.now().microsecondsSinceEpoch}',
: draft.extractedTraits,
); );
state = state.copyWith( final places = await _api.fetchPlaces();
places: [place, ...state.places], final selectedPlace = places.isEmpty ? null : places.first.id;
selectedPlaceId: place.id, state = AsyncData(
reviewDraft: const VoiceReviewDraft( value.copyWith(
placeName: '', places: places,
duration: Duration.zero, selectedPlaceId: selectedPlace,
extractedTraits: {}, reviewDraft: const VoiceReviewDraft(
suggestedIntents: {}, placeName: '',
evidence: [], 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,
},
),
];
}
} }

View File

@@ -7,4 +7,13 @@ server {
location / { location / {
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
location = /graphql {
proxy_pass http://mapflow-api-0bvuyz:4000/graphql;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
} }

View File

@@ -228,7 +228,7 @@ packages:
source: hosted source: hosted
version: "1.0.3" version: "1.0.3"
http: http:
dependency: transitive dependency: "direct main"
description: description:
name: http name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"

View File

@@ -37,6 +37,7 @@ dependencies:
flutter_riverpod: ^3.3.1 flutter_riverpod: ^3.3.1
flutter_map: ^8.3.0 flutter_map: ^8.3.0
latlong2: ^0.9.1 latlong2: ^0.9.1
http: ^1.6.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -1,4 +1,4 @@
import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -9,8 +9,6 @@ void main() {
await tester.pumpWidget(const ProviderScope(child: MapflowApp())); await tester.pumpWidget(const ProviderScope(child: MapflowApp()));
await tester.pump(); await tester.pump();
expect(find.text('выдохнуть'), findsOneWidget); expect(find.byType(FlutterMap), findsOneWidget);
expect(find.text('свидание'), findsOneWidget);
expect(find.byIcon(Icons.add_location_alt_outlined), findsWidgets);
}); });
} }