diff --git a/lib/api/mapflow_api.dart b/lib/api/mapflow_api.dart new file mode 100644 index 0000000..b11d1c3 --- /dev/null +++ b/lib/api/mapflow_api.dart @@ -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> 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; + return places.map((item) { + final place = item as Map; + 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), + ); + }).toList(); + } + + Future 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> _graphql( + String query, { + Map? 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; + final errors = payload['errors']; + if (errors is List && errors.isNotEmpty) { + throw StateError(jsonEncode(errors)); + } + + return payload['data'] as Map; + } + + Set _traitsFromExperiences(List experiences) { + final traits = {}; + for (final item in experiences) { + final experience = item as Map; + final analysis = experience['analysis']; + if (analysis is! Map) { + 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; + } +} diff --git a/lib/models/experience_models.dart b/lib/models/experience_models.dart deleted file mode 100644 index fce758f..0000000 --- a/lib/models/experience_models.dart +++ /dev/null @@ -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 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; -} diff --git a/lib/screens/experience_map_screen.dart b/lib/screens/experience_map_screen.dart deleted file mode 100644 index 91f2599..0000000 --- a/lib/screens/experience_map_screen.dart +++ /dev/null @@ -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( - 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), - }; -} diff --git a/lib/screens/mapflow_shell.dart b/lib/screens/mapflow_shell.dart index 50cc64c..169abc9 100644 --- a/lib/screens/mapflow_shell.dart +++ b/lib/screens/mapflow_shell.dart @@ -11,11 +11,27 @@ import '../state/place_controller.dart'; class MapflowShell extends ConsumerWidget { 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); + final PlaceState state; + @override Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(placeControllerProvider); final selected = state.selectedPlace; 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 { const _IntentBar({required this.intent}); @@ -173,6 +259,10 @@ class _PlaceCarousel extends StatelessWidget { @override Widget build(BuildContext context) { + if (places.isEmpty) { + return const SizedBox.shrink(); + } + return SizedBox( height: 172, child: ListView.separated( @@ -216,19 +306,25 @@ class _PlacePhotoCard extends StatelessWidget { child: Stack( fit: StackFit.expand, children: [ - PageView.builder( - itemCount: place.photoUrls.length, - itemBuilder: (context, index) { - return Image.network( - place.photoUrls[index], - fit: BoxFit.cover, - errorBuilder: (_, _, _) => Container( - color: const Color(0xFFE0D8CA), - child: const Icon(Icons.place_outlined), - ), - ); - }, - ), + if (place.photoUrls.isEmpty) + const ColoredBox( + color: Color(0xFF0F766E), + child: Icon(Icons.place_outlined, color: Colors.white), + ) + else + PageView.builder( + itemCount: place.photoUrls.length, + 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( decoration: BoxDecoration( gradient: LinearGradient( @@ -273,37 +369,23 @@ class _AddExperienceFlowState extends ConsumerState { static const _minimumVoiceSeconds = 30; Timer? _timer; + late final TextEditingController _placeNameController; var _step = 0; var _seconds = 0; var _recording = false; - _GooglePlaceStub? _selectedGooglePlace; + var _submitting = false; + PlaceRecommendation? _selectedPlace; - static const _nearbyPlaces = [ - _GooglePlaceStub( - name: 'Secret Garden', - area: '120 m', - 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 + void initState() { + super.initState(); + _placeNameController = TextEditingController(); + } @override void dispose() { _timer?.cancel(); + _placeNameController.dispose(); super.dispose(); } @@ -324,6 +406,7 @@ class _AddExperienceFlowState extends ConsumerState { @override Widget build(BuildContext context) { final controller = ref.read(placeControllerProvider.notifier); + final places = ref.watch(placeControllerProvider).value?.places ?? []; final time = '${(_seconds ~/ 60).toString().padLeft(2, '0')}:' '${(_seconds % 60).toString().padLeft(2, '0')}'; @@ -331,29 +414,38 @@ class _AddExperienceFlowState extends ConsumerState { final content = switch (_step) { 0 => _IntroStep(onNext: () => setState(() => _step = 1)), 1 => _PlaceStep( - places: _nearbyPlaces, + places: places, + controller: _placeNameController, onSelect: (place) { setState(() { - _selectedGooglePlace = place; + _selectedPlace = place; + _placeNameController.text = place.name; _step = 2; }); controller.setReviewPlace(place.name); }, + onManualNext: () { + controller.setReviewPlace(_placeNameController.text); + setState(() => _step = 2); + }, ), _ => _VoiceStep( - place: _selectedGooglePlace, + placeName: _placeNameController.text, seconds: _seconds, minimumSeconds: _minimumVoiceSeconds, time: time, isRecording: _recording, + isSubmitting: _submitting, canContinue: _seconds >= _minimumVoiceSeconds, onToggleRecording: _toggleRecording, - onNext: () { - final coordinate = - _selectedGooglePlace?.coordinate ?? widget.coordinate; - controller.setReviewPlace(_selectedGooglePlace?.name ?? ''); - controller.analyzeVoiceReview(); - controller.publishReview(coordinate: coordinate); + onNext: () async { + setState(() => _submitting = true); + final coordinate = _selectedPlace?.coordinate ?? widget.coordinate; + controller.setReviewPlace(_placeNameController.text); + await controller.publishReview(coordinate: coordinate); + if (!context.mounted) { + return; + } Navigator.of(context).pop(); }, ), @@ -430,10 +522,17 @@ class _IntroStep 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 ValueChanged<_GooglePlaceStub> onSelect; + final List places; + final TextEditingController controller; + final ValueChanged onSelect; + final VoidCallback onManualNext; @override Widget build(BuildContext context) { @@ -442,15 +541,19 @@ class _PlaceStep extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Выбери место рядом', + 'Место', style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w900, letterSpacing: 0, ), ), - const SizedBox(height: 6), - const Text('Покажем Google Places по твоей геолокации.'), const SizedBox(height: 16), + TextField( + controller: controller, + decoration: const InputDecoration(hintText: 'Название места'), + textInputAction: TextInputAction.done, + ), + const SizedBox(height: 12), Expanded( child: ListView.separated( itemCount: places.length, @@ -472,34 +575,36 @@ class _PlaceStep extends StatelessWidget { place.name, style: const TextStyle(fontWeight: FontWeight.w800), ), - trailing: Text(place.area), ); }, ), ), ], ), + action: FilledButton(onPressed: onManualNext, child: const Text('Далее')), ); } } class _VoiceStep extends StatelessWidget { const _VoiceStep({ - required this.place, + required this.placeName, required this.seconds, required this.minimumSeconds, required this.time, required this.isRecording, + required this.isSubmitting, required this.canContinue, required this.onToggleRecording, required this.onNext, }); - final _GooglePlaceStub? place; + final String placeName; final int seconds; final int minimumSeconds; final String time; final bool isRecording; + final bool isSubmitting; final bool canContinue; final VoidCallback onToggleRecording; final VoidCallback onNext; @@ -511,7 +616,7 @@ class _VoiceStep extends StatelessWidget { children: [ const Spacer(), Text( - place?.name ?? 'Место', + placeName.trim().isEmpty ? 'Место' : placeName.trim(), textAlign: TextAlign.center, style: Theme.of( context, @@ -524,7 +629,7 @@ class _VoiceStep extends StatelessWidget { width: 132, height: 132, child: FilledButton( - onPressed: onToggleRecording, + onPressed: isSubmitting ? null : onToggleRecording, style: FilledButton.styleFrom(shape: const CircleBorder()), child: Icon(isRecording ? Icons.stop : Icons.mic, size: 54), ), @@ -544,8 +649,8 @@ class _VoiceStep extends StatelessWidget { ], ), action: FilledButton( - onPressed: canContinue ? onNext : null, - child: const Text('Далее'), + onPressed: canContinue && !isSubmitting ? onNext : null, + 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; -} diff --git a/lib/state/experience_controller.dart b/lib/state/experience_controller.dart deleted file mode 100644 index f809751..0000000 --- a/lib/state/experience_controller.dart +++ /dev/null @@ -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.new, - ); - -class ExperienceState { - const ExperienceState({ - required this.experiences, - required this.selectedExperienceId, - required this.filterEmotion, - required this.profile, - }); - - final List experiences; - final String? selectedExperienceId; - final ExperienceEmotion? filterEmotion; - final ExperienceAuthor profile; - - List 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? 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 { - @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 _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: 'месяц назад', - ), - ]; - } -} diff --git a/lib/state/place_controller.dart b/lib/state/place_controller.dart index b85fd62..3ce5c93 100644 --- a/lib/state/place_controller.dart +++ b/lib/state/place_controller.dart @@ -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.new, -); +final placeControllerProvider = + AsyncNotifierProvider(PlaceController.new); class PlaceState { const PlaceState({ @@ -55,14 +55,16 @@ class PlaceState { } } -class PlaceController extends Notifier { +class PlaceController extends AsyncNotifier { + final _api = MapflowApi(); + @override - PlaceState build() { - final places = _seedPlaces(); + Future 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 { } 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 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 _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, - }, - ), - ]; - } } diff --git a/nginx.conf b/nginx.conf index 0eea1de..642f32b 100644 --- a/nginx.conf +++ b/nginx.conf @@ -7,4 +7,13 @@ server { location / { 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; + } } diff --git a/pubspec.lock b/pubspec.lock index 3f2c4ff..f125445 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -228,7 +228,7 @@ packages: source: hosted version: "1.0.3" http: - dependency: transitive + dependency: "direct main" description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" diff --git a/pubspec.yaml b/pubspec.yaml index 5caef61..ec8b014 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: flutter_riverpod: ^3.3.1 flutter_map: ^8.3.0 latlong2: ^0.9.1 + http: ^1.6.0 dev_dependencies: flutter_test: diff --git a/test/widget_test.dart b/test/widget_test.dart index 60d4080..dc0b224 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -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_riverpod/flutter_riverpod.dart'; @@ -9,8 +9,6 @@ void main() { await tester.pumpWidget(const ProviderScope(child: MapflowApp())); await tester.pump(); - expect(find.text('выдохнуть'), findsOneWidget); - expect(find.text('свидание'), findsOneWidget); - expect(find.byIcon(Icons.add_location_alt_outlined), findsWidgets); + expect(find.byType(FlutterMap), findsOneWidget); }); }