Files
flutter/lib/screens/mapflow_shell.dart
2026-05-05 11:58:38 +07:00

624 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}