Load map places from backend
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 3m26s
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 3m26s
This commit is contained in:
141
lib/api/mapflow_api.dart
Normal file
141
lib/api/mapflow_api.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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: 'месяц назад',
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user