Initial Flutter app
This commit is contained in:
99
lib/main.dart
Normal file
99
lib/main.dart
Normal file
@@ -0,0 +1,99 @@
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'screens/mapflow_shell.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
runApp(const ProviderScope(child: MapflowApp()));
|
||||
}
|
||||
|
||||
class MapflowApp extends StatelessWidget {
|
||||
const MapflowApp({super.key});
|
||||
|
||||
ThemeData _buildTheme() {
|
||||
final colorScheme = ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF0F766E),
|
||||
primary: const Color(0xFF0F766E),
|
||||
secondary: const Color(0xFFE11D48),
|
||||
surface: const Color(0xFFFFFBF5),
|
||||
);
|
||||
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: colorScheme,
|
||||
scaffoldBackgroundColor: const Color(0xFFF7F3EA),
|
||||
fontFamily: 'SF Pro Display',
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Color(0xFFF7F3EA),
|
||||
foregroundColor: Color(0xFF17211D),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 0,
|
||||
color: const Color(0xFFFFFBF5),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: const BorderSide(color: Color(0xFFE0D8CA)),
|
||||
),
|
||||
),
|
||||
chipTheme: const ChipThemeData(
|
||||
shape: StadiumBorder(side: BorderSide(color: Color(0xFFE0D8CA))),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFFFFFFF),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: Color(0xFFD8D0C3)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: Color(0xFFD8D0C3)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: BorderSide(color: colorScheme.primary, width: 1.5),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shortcuts =
|
||||
Map<ShortcutActivator, Intent>.of(WidgetsApp.defaultShortcuts)
|
||||
..[const SingleActivator(LogicalKeyboardKey.tab)] =
|
||||
const NextFocusIntent()
|
||||
..[const SingleActivator(LogicalKeyboardKey.tab, shift: true)] =
|
||||
const PreviousFocusIntent();
|
||||
|
||||
return MaterialApp(
|
||||
title: 'MapFlow',
|
||||
debugShowCheckedModeBanner: false,
|
||||
scrollBehavior: const MaterialScrollBehavior().copyWith(
|
||||
scrollbars: true,
|
||||
dragDevices: {
|
||||
PointerDeviceKind.touch,
|
||||
PointerDeviceKind.mouse,
|
||||
PointerDeviceKind.trackpad,
|
||||
PointerDeviceKind.stylus,
|
||||
},
|
||||
),
|
||||
theme: _buildTheme(),
|
||||
builder: (context, child) {
|
||||
return Shortcuts(
|
||||
shortcuts: shortcuts,
|
||||
child: FocusTraversalGroup(
|
||||
policy: ReadingOrderTraversalPolicy(),
|
||||
child: child ?? const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
},
|
||||
home: const MapflowShell(),
|
||||
);
|
||||
}
|
||||
}
|
||||
78
lib/models/experience_models.dart
Normal file
78
lib/models/experience_models.dart
Normal file
@@ -0,0 +1,78 @@
|
||||
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;
|
||||
}
|
||||
174
lib/models/place_models.dart
Normal file
174
lib/models/place_models.dart
Normal file
@@ -0,0 +1,174 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
enum PlaceTrait {
|
||||
calm,
|
||||
alive,
|
||||
cozy,
|
||||
cold,
|
||||
status,
|
||||
simple,
|
||||
free,
|
||||
formal,
|
||||
private,
|
||||
open,
|
||||
social,
|
||||
solo,
|
||||
beautiful,
|
||||
neutral,
|
||||
unusual,
|
||||
clear,
|
||||
focused,
|
||||
transit,
|
||||
}
|
||||
|
||||
extension PlaceTraitText on PlaceTrait {
|
||||
String get label {
|
||||
return switch (this) {
|
||||
PlaceTrait.calm => 'спокойное',
|
||||
PlaceTrait.alive => 'живое',
|
||||
PlaceTrait.cozy => 'уютное',
|
||||
PlaceTrait.cold => 'холодное',
|
||||
PlaceTrait.status => 'статусное',
|
||||
PlaceTrait.simple => 'простое',
|
||||
PlaceTrait.free => 'свободное',
|
||||
PlaceTrait.formal => 'формальное',
|
||||
PlaceTrait.private => 'приватное',
|
||||
PlaceTrait.open => 'открытое',
|
||||
PlaceTrait.social => 'для общения',
|
||||
PlaceTrait.solo => 'для себя',
|
||||
PlaceTrait.beautiful => 'красивое',
|
||||
PlaceTrait.neutral => 'нейтральное',
|
||||
PlaceTrait.unusual => 'необычное',
|
||||
PlaceTrait.clear => 'понятное',
|
||||
PlaceTrait.focused => 'помогает собраться',
|
||||
PlaceTrait.transit => 'транзитное',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
enum UserIntent { exhale, date, meet, focus, move, surprise, alone, impress }
|
||||
|
||||
extension UserIntentText on UserIntent {
|
||||
String get title {
|
||||
return switch (this) {
|
||||
UserIntent.exhale => 'выдохнуть',
|
||||
UserIntent.date => 'свидание',
|
||||
UserIntent.meet => 'встретиться',
|
||||
UserIntent.focus => 'поработать',
|
||||
UserIntent.move => 'движ',
|
||||
UserIntent.surprise => 'удивиться',
|
||||
UserIntent.alone => 'побыть одному',
|
||||
UserIntent.impress => 'привести кого-то',
|
||||
};
|
||||
}
|
||||
|
||||
String get subtitle {
|
||||
return switch (this) {
|
||||
UserIntent.exhale => 'тихо, мягко, без давления',
|
||||
UserIntent.date => 'красиво и лично',
|
||||
UserIntent.meet => 'общение без лишней формальности',
|
||||
UserIntent.focus => 'собраться и не выпадать',
|
||||
UserIntent.move => 'живо, шумно, с энергией',
|
||||
UserIntent.surprise => 'не как обычно',
|
||||
UserIntent.alone => 'быть в своем ритме',
|
||||
UserIntent.impress => 'место должно держать момент',
|
||||
};
|
||||
}
|
||||
|
||||
IconData get icon {
|
||||
return switch (this) {
|
||||
UserIntent.exhale => Icons.air_outlined,
|
||||
UserIntent.date => Icons.favorite_border,
|
||||
UserIntent.meet => Icons.forum_outlined,
|
||||
UserIntent.focus => Icons.center_focus_strong_outlined,
|
||||
UserIntent.move => Icons.bolt_outlined,
|
||||
UserIntent.surprise => Icons.auto_awesome_outlined,
|
||||
UserIntent.alone => Icons.person_outline,
|
||||
UserIntent.impress => Icons.diamond_outlined,
|
||||
};
|
||||
}
|
||||
|
||||
Set<PlaceTrait> get traits {
|
||||
return switch (this) {
|
||||
UserIntent.exhale => {PlaceTrait.calm, PlaceTrait.cozy, PlaceTrait.solo},
|
||||
UserIntent.date => {
|
||||
PlaceTrait.private,
|
||||
PlaceTrait.beautiful,
|
||||
PlaceTrait.social,
|
||||
},
|
||||
UserIntent.meet => {PlaceTrait.social, PlaceTrait.free, PlaceTrait.alive},
|
||||
UserIntent.focus => {
|
||||
PlaceTrait.focused,
|
||||
PlaceTrait.calm,
|
||||
PlaceTrait.neutral,
|
||||
},
|
||||
UserIntent.move => {PlaceTrait.alive, PlaceTrait.open, PlaceTrait.social},
|
||||
UserIntent.surprise => {
|
||||
PlaceTrait.unusual,
|
||||
PlaceTrait.alive,
|
||||
PlaceTrait.open,
|
||||
},
|
||||
UserIntent.alone => {PlaceTrait.solo, PlaceTrait.free, PlaceTrait.calm},
|
||||
UserIntent.impress => {
|
||||
PlaceTrait.status,
|
||||
PlaceTrait.beautiful,
|
||||
PlaceTrait.private,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class PlaceRecommendation {
|
||||
const PlaceRecommendation({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.area,
|
||||
required this.photoUrls,
|
||||
required this.coordinate,
|
||||
required this.traits,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final String area;
|
||||
final List<String> photoUrls;
|
||||
final LatLng coordinate;
|
||||
final Set<PlaceTrait> traits;
|
||||
|
||||
String get coverPhotoUrl => photoUrls.first;
|
||||
}
|
||||
|
||||
class VoiceReviewDraft {
|
||||
const VoiceReviewDraft({
|
||||
required this.placeName,
|
||||
required this.duration,
|
||||
required this.extractedTraits,
|
||||
required this.suggestedIntents,
|
||||
required this.evidence,
|
||||
});
|
||||
|
||||
final String placeName;
|
||||
final Duration duration;
|
||||
final Set<PlaceTrait> extractedTraits;
|
||||
final Set<UserIntent> suggestedIntents;
|
||||
final List<String> evidence;
|
||||
|
||||
bool get isLongEnough => duration.inSeconds >= 30;
|
||||
|
||||
VoiceReviewDraft copyWith({
|
||||
String? placeName,
|
||||
Duration? duration,
|
||||
Set<PlaceTrait>? extractedTraits,
|
||||
Set<UserIntent>? suggestedIntents,
|
||||
List<String>? evidence,
|
||||
}) {
|
||||
return VoiceReviewDraft(
|
||||
placeName: placeName ?? this.placeName,
|
||||
duration: duration ?? this.duration,
|
||||
extractedTraits: extractedTraits ?? this.extractedTraits,
|
||||
suggestedIntents: suggestedIntents ?? this.suggestedIntents,
|
||||
evidence: evidence ?? this.evidence,
|
||||
);
|
||||
}
|
||||
}
|
||||
495
lib/screens/experience_map_screen.dart
Normal file
495
lib/screens/experience_map_screen.dart
Normal file
@@ -0,0 +1,495 @@
|
||||
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),
|
||||
};
|
||||
}
|
||||
623
lib/screens/mapflow_shell.dart
Normal file
623
lib/screens/mapflow_shell.dart
Normal file
@@ -0,0 +1,623 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/place_models.dart';
|
||||
import '../state/place_controller.dart';
|
||||
|
||||
class MapflowShell extends ConsumerWidget {
|
||||
const MapflowShell({super.key});
|
||||
|
||||
static const _center = LatLng(10.7718, 106.6982);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(placeControllerProvider);
|
||||
final selected = state.selectedPlace;
|
||||
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
FlutterMap(
|
||||
options: MapOptions(
|
||||
initialCenter: selected?.coordinate ?? _center,
|
||||
initialZoom: 14.2,
|
||||
minZoom: 3,
|
||||
maxZoom: 18,
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.mapflow.app',
|
||||
),
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
for (final place in state.recommendations)
|
||||
Marker(
|
||||
width: 52,
|
||||
height: 52,
|
||||
point: place.coordinate,
|
||||
child: _PlaceMarker(
|
||||
selected: selected?.id == place.id,
|
||||
onTap: () => ref
|
||||
.read(placeControllerProvider.notifier)
|
||||
.selectPlace(place.id),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const RichAttributionWidget(
|
||||
attributions: [
|
||||
TextSourceAttribution('OpenStreetMap contributors'),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: _IntentBar(intent: state.intent),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SafeArea(
|
||||
top: false,
|
||||
child: _PlaceCarousel(
|
||||
places: state.recommendations,
|
||||
onSelect: (place) => ref
|
||||
.read(placeControllerProvider.notifier)
|
||||
.selectPlace(place.id),
|
||||
),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: FloatingActionButton(
|
||||
onPressed: () => _openAddFlow(context, selected?.coordinate),
|
||||
child: const Icon(Icons.add_location_alt_outlined),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openAddFlow(BuildContext context, LatLng? coordinate) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => AddExperienceFlow(coordinate: coordinate ?? _center),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IntentBar extends ConsumerWidget {
|
||||
const _IntentBar({required this.intent});
|
||||
|
||||
final UserIntent intent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final controller = ref.read(placeControllerProvider.notifier);
|
||||
|
||||
return Container(
|
||||
height: 58,
|
||||
margin: const EdgeInsets.fromLTRB(10, 8, 10, 0),
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: UserIntent.values.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||
itemBuilder: (context, index) {
|
||||
final item = UserIntent.values[index];
|
||||
return ChoiceChip(
|
||||
avatar: Icon(item.icon, size: 17),
|
||||
label: Text(item.title),
|
||||
selected: item == intent,
|
||||
onSelected: (_) => controller.selectIntent(item),
|
||||
backgroundColor: const Color(0xFFFFFBF5),
|
||||
selectedColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaceMarker extends StatelessWidget {
|
||||
const _PlaceMarker({required this.selected, required this.onTap});
|
||||
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = Theme.of(context).colorScheme.primary;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
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(Icons.place, color: selected ? Colors.white : color),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaceCarousel extends StatelessWidget {
|
||||
const _PlaceCarousel({required this.places, required this.onSelect});
|
||||
|
||||
final List<PlaceRecommendation> places;
|
||||
final ValueChanged<PlaceRecommendation> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 172,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
|
||||
itemCount: places.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 10),
|
||||
itemBuilder: (context, index) {
|
||||
final place = places[index];
|
||||
return _PlacePhotoCard(place: place, onTap: () => onSelect(place));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlacePhotoCard extends StatelessWidget {
|
||||
const _PlacePhotoCard({required this.place, required this.onTap});
|
||||
|
||||
final PlaceRecommendation place;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 150,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: Colors.white),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x33000000),
|
||||
blurRadius: 16,
|
||||
offset: Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
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),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.transparent, Color(0xCC000000)],
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 10,
|
||||
right: 10,
|
||||
bottom: 12,
|
||||
child: Text(
|
||||
place.name,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1.05,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AddExperienceFlow extends ConsumerStatefulWidget {
|
||||
const AddExperienceFlow({super.key, required this.coordinate});
|
||||
|
||||
final LatLng coordinate;
|
||||
|
||||
@override
|
||||
ConsumerState<AddExperienceFlow> createState() => _AddExperienceFlowState();
|
||||
}
|
||||
|
||||
class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
|
||||
Timer? _timer;
|
||||
var _step = 0;
|
||||
var _seconds = 0;
|
||||
var _recording = false;
|
||||
_GooglePlaceStub? _selectedGooglePlace;
|
||||
|
||||
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 dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggleRecording() {
|
||||
setState(() => _recording = !_recording);
|
||||
if (_recording) {
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
setState(() => _seconds += 1);
|
||||
ref
|
||||
.read(placeControllerProvider.notifier)
|
||||
.setReviewDuration(Duration(seconds: _seconds));
|
||||
});
|
||||
} else {
|
||||
_timer?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = ref.read(placeControllerProvider.notifier);
|
||||
final time =
|
||||
'${(_seconds ~/ 60).toString().padLeft(2, '0')}:'
|
||||
'${(_seconds % 60).toString().padLeft(2, '0')}';
|
||||
|
||||
final content = switch (_step) {
|
||||
0 => _IntroStep(onNext: () => setState(() => _step = 1)),
|
||||
1 => _PlaceStep(
|
||||
places: _nearbyPlaces,
|
||||
onSelect: (place) {
|
||||
setState(() {
|
||||
_selectedGooglePlace = place;
|
||||
_step = 2;
|
||||
});
|
||||
controller.setReviewPlace(place.name);
|
||||
},
|
||||
),
|
||||
_ => _VoiceStep(
|
||||
place: _selectedGooglePlace,
|
||||
seconds: _seconds,
|
||||
time: time,
|
||||
isRecording: _recording,
|
||||
canContinue: _seconds >= 60,
|
||||
onToggleRecording: _toggleRecording,
|
||||
onNext: () {
|
||||
final coordinate =
|
||||
_selectedGooglePlace?.coordinate ?? widget.coordinate;
|
||||
controller.setReviewPlace(_selectedGooglePlace?.name ?? '');
|
||||
controller.analyzeVoiceReview();
|
||||
controller.publishReview(coordinate: coordinate);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
};
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF7F3EA),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16,
|
||||
10,
|
||||
16,
|
||||
MediaQuery.viewInsetsOf(context).bottom + 18,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_StoryProgress(
|
||||
step: _step,
|
||||
total: 3,
|
||||
onClose: () => Navigator.of(context).pop(),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Expanded(
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
child: KeyedSubtree(key: ValueKey(_step), child: content),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IntroStep extends StatelessWidget {
|
||||
const _IntroStep({required this.onNext});
|
||||
|
||||
final VoidCallback onNext;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _StepLayout(
|
||||
body: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 70,
|
||||
height: 70,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE11D48),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(Icons.graphic_eq, color: Colors.white, size: 38),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
Text(
|
||||
'Расскажи про место голосом',
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: 0,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'Поделись ощущением, а не оценкой. Мы разберем голос через AI и удалим аудио после обработки.',
|
||||
),
|
||||
],
|
||||
),
|
||||
action: FilledButton(onPressed: onNext, child: const Text('Далее')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PlaceStep extends StatelessWidget {
|
||||
const _PlaceStep({required this.places, required this.onSelect});
|
||||
|
||||
final List<_GooglePlaceStub> places;
|
||||
final ValueChanged<_GooglePlaceStub> onSelect;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _StepLayout(
|
||||
body: Column(
|
||||
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),
|
||||
Expanded(
|
||||
child: ListView.separated(
|
||||
itemCount: places.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, index) {
|
||||
final place = places[index];
|
||||
return ListTile(
|
||||
onTap: () => onSelect(place),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 8,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
tileColor: const Color(0xFFFFFBF5),
|
||||
leading: const Icon(Icons.place_outlined),
|
||||
title: Text(
|
||||
place.name,
|
||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
trailing: Text(place.area),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VoiceStep extends StatelessWidget {
|
||||
const _VoiceStep({
|
||||
required this.place,
|
||||
required this.seconds,
|
||||
required this.time,
|
||||
required this.isRecording,
|
||||
required this.canContinue,
|
||||
required this.onToggleRecording,
|
||||
required this.onNext,
|
||||
});
|
||||
|
||||
final _GooglePlaceStub? place;
|
||||
final int seconds;
|
||||
final String time;
|
||||
final bool isRecording;
|
||||
final bool canContinue;
|
||||
final VoidCallback onToggleRecording;
|
||||
final VoidCallback onNext;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _StepLayout(
|
||||
body: Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
Text(
|
||||
place?.name ?? 'Место',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text('Минимум 60 секунд', textAlign: TextAlign.center),
|
||||
const SizedBox(height: 26),
|
||||
SizedBox(
|
||||
width: 132,
|
||||
height: 132,
|
||||
child: FilledButton(
|
||||
onPressed: onToggleRecording,
|
||||
style: FilledButton.styleFrom(shape: const CircleBorder()),
|
||||
child: Icon(isRecording ? Icons.stop : Icons.mic, size: 54),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
Text(
|
||||
time,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w900),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
LinearProgressIndicator(value: (seconds / 60).clamp(0.0, 1.0)),
|
||||
const Spacer(),
|
||||
],
|
||||
),
|
||||
action: FilledButton(
|
||||
onPressed: canContinue ? onNext : null,
|
||||
child: const Text('Далее'),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StepLayout extends StatelessWidget {
|
||||
const _StepLayout({required this.body, this.action});
|
||||
|
||||
final Widget body;
|
||||
final Widget? action;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(child: body),
|
||||
if (action != null) SizedBox(width: double.infinity, child: action),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StoryProgress extends StatelessWidget {
|
||||
const _StoryProgress({
|
||||
required this.step,
|
||||
required this.total,
|
||||
required this.onClose,
|
||||
});
|
||||
|
||||
final int step;
|
||||
final int total;
|
||||
final VoidCallback onClose;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
for (var index = 0; index < total; index++) ...[
|
||||
Expanded(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
height: 5,
|
||||
decoration: BoxDecoration(
|
||||
color: index <= step
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: const Color(0xFFE0D8CA),
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (index != total - 1) const SizedBox(width: 6),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
IconButton(
|
||||
onPressed: onClose,
|
||||
icon: const Icon(Icons.close),
|
||||
tooltip: 'Закрыть',
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GooglePlaceStub {
|
||||
const _GooglePlaceStub({
|
||||
required this.name,
|
||||
required this.area,
|
||||
required this.coordinate,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String area;
|
||||
final LatLng coordinate;
|
||||
}
|
||||
209
lib/state/experience_controller.dart
Normal file
209
lib/state/experience_controller.dart
Normal file
@@ -0,0 +1,209 @@
|
||||
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: 'месяц назад',
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
233
lib/state/place_controller.dart
Normal file
233
lib/state/place_controller.dart
Normal file
@@ -0,0 +1,233 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/place_models.dart';
|
||||
|
||||
final placeControllerProvider = NotifierProvider<PlaceController, PlaceState>(
|
||||
PlaceController.new,
|
||||
);
|
||||
|
||||
class PlaceState {
|
||||
const PlaceState({
|
||||
required this.intent,
|
||||
required this.places,
|
||||
required this.selectedPlaceId,
|
||||
required this.reviewDraft,
|
||||
});
|
||||
|
||||
final UserIntent intent;
|
||||
final List<PlaceRecommendation> places;
|
||||
final String? selectedPlaceId;
|
||||
final VoiceReviewDraft reviewDraft;
|
||||
|
||||
List<PlaceRecommendation> get recommendations {
|
||||
final wanted = intent.traits;
|
||||
final ranked = [...places]
|
||||
..sort((a, b) {
|
||||
final aScore = a.traits.intersection(wanted).length;
|
||||
final bScore = b.traits.intersection(wanted).length;
|
||||
return bScore.compareTo(aScore);
|
||||
});
|
||||
return ranked.take(4).toList();
|
||||
}
|
||||
|
||||
PlaceRecommendation? get selectedPlace {
|
||||
for (final place in places) {
|
||||
if (place.id == selectedPlaceId) {
|
||||
return place;
|
||||
}
|
||||
}
|
||||
return recommendations.isEmpty ? null : recommendations.first;
|
||||
}
|
||||
|
||||
PlaceState copyWith({
|
||||
UserIntent? intent,
|
||||
List<PlaceRecommendation>? places,
|
||||
String? selectedPlaceId,
|
||||
VoiceReviewDraft? reviewDraft,
|
||||
}) {
|
||||
return PlaceState(
|
||||
intent: intent ?? this.intent,
|
||||
places: places ?? this.places,
|
||||
selectedPlaceId: selectedPlaceId ?? this.selectedPlaceId,
|
||||
reviewDraft: reviewDraft ?? this.reviewDraft,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PlaceController extends Notifier<PlaceState> {
|
||||
@override
|
||||
PlaceState build() {
|
||||
final places = _seedPlaces();
|
||||
return PlaceState(
|
||||
intent: UserIntent.exhale,
|
||||
places: places,
|
||||
selectedPlaceId: places.first.id,
|
||||
reviewDraft: const VoiceReviewDraft(
|
||||
placeName: '',
|
||||
duration: Duration.zero,
|
||||
extractedTraits: {},
|
||||
suggestedIntents: {},
|
||||
evidence: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void selectIntent(UserIntent intent) {
|
||||
PlaceRecommendation? next;
|
||||
for (final place in state.places) {
|
||||
if (place.traits.intersection(intent.traits).isNotEmpty) {
|
||||
next = place;
|
||||
break;
|
||||
}
|
||||
}
|
||||
state = state.copyWith(intent: intent, selectedPlaceId: next?.id);
|
||||
}
|
||||
|
||||
void selectPlace(String placeId) {
|
||||
state = state.copyWith(selectedPlaceId: placeId);
|
||||
}
|
||||
|
||||
void setReviewPlace(String placeName) {
|
||||
state = state.copyWith(
|
||||
reviewDraft: state.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: [
|
||||
'можно нормально поговорить',
|
||||
'место мягкое, не давит',
|
||||
'туда хочется привести человека вечером',
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
state = state.copyWith(
|
||||
places: [place, ...state.places],
|
||||
selectedPlaceId: place.id,
|
||||
reviewDraft: const VoiceReviewDraft(
|
||||
placeName: '',
|
||||
duration: Duration.zero,
|
||||
extractedTraits: {},
|
||||
suggestedIntents: {},
|
||||
evidence: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<PlaceRecommendation> _seedPlaces() {
|
||||
return const [
|
||||
PlaceRecommendation(
|
||||
id: 'secret-garden',
|
||||
name: 'Secret Garden',
|
||||
area: 'District 1',
|
||||
photoUrls: [
|
||||
'https://images.unsplash.com/photo-1552566626-52f8b828add9?auto=format&fit=crop&w=600&q=80',
|
||||
'https://images.unsplash.com/photo-1559339352-11d035aa65de?auto=format&fit=crop&w=600&q=80',
|
||||
'https://images.unsplash.com/photo-1521017432531-fbd92d768814?auto=format&fit=crop&w=600&q=80',
|
||||
],
|
||||
coordinate: LatLng(10.7752, 106.7009),
|
||||
traits: {
|
||||
PlaceTrait.calm,
|
||||
PlaceTrait.cozy,
|
||||
PlaceTrait.private,
|
||||
PlaceTrait.beautiful,
|
||||
PlaceTrait.social,
|
||||
},
|
||||
),
|
||||
PlaceRecommendation(
|
||||
id: 'workshop',
|
||||
name: 'The Workshop',
|
||||
area: 'District 1',
|
||||
photoUrls: [
|
||||
'https://images.unsplash.com/photo-1495474472287-4d71bcdd2085?auto=format&fit=crop&w=600&q=80',
|
||||
'https://images.unsplash.com/photo-1501339847302-ac426a4a7cbb?auto=format&fit=crop&w=600&q=80',
|
||||
'https://images.unsplash.com/photo-1511920170033-f8396924c348?auto=format&fit=crop&w=600&q=80',
|
||||
],
|
||||
coordinate: LatLng(10.7740, 106.7042),
|
||||
traits: {
|
||||
PlaceTrait.focused,
|
||||
PlaceTrait.calm,
|
||||
PlaceTrait.neutral,
|
||||
PlaceTrait.solo,
|
||||
},
|
||||
),
|
||||
PlaceRecommendation(
|
||||
id: 'oc-dao',
|
||||
name: 'Oc Dao',
|
||||
area: 'District 1',
|
||||
photoUrls: [
|
||||
'https://images.unsplash.com/photo-1555396273-367ea4eb4db5?auto=format&fit=crop&w=600&q=80',
|
||||
'https://images.unsplash.com/photo-1544025162-d76694265947?auto=format&fit=crop&w=600&q=80',
|
||||
'https://images.unsplash.com/photo-1551218808-94e220e084d2?auto=format&fit=crop&w=600&q=80',
|
||||
],
|
||||
coordinate: LatLng(10.7607, 106.6898),
|
||||
traits: {
|
||||
PlaceTrait.alive,
|
||||
PlaceTrait.open,
|
||||
PlaceTrait.social,
|
||||
PlaceTrait.unusual,
|
||||
},
|
||||
),
|
||||
PlaceRecommendation(
|
||||
id: 'l-usine',
|
||||
name: 'L\'Usine',
|
||||
area: 'Dong Khoi',
|
||||
photoUrls: [
|
||||
'https://images.unsplash.com/photo-1514933651103-005eec06c04b?auto=format&fit=crop&w=600&q=80',
|
||||
'https://images.unsplash.com/photo-1550966871-3ed3cdb5ed0c?auto=format&fit=crop&w=600&q=80',
|
||||
'https://images.unsplash.com/photo-1551632436-cbf8dd35adfa?auto=format&fit=crop&w=600&q=80',
|
||||
],
|
||||
coordinate: LatLng(10.7755, 106.7038),
|
||||
traits: {
|
||||
PlaceTrait.status,
|
||||
PlaceTrait.beautiful,
|
||||
PlaceTrait.private,
|
||||
PlaceTrait.clear,
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user