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 '../auth/telegram_login_button.dart'; import '../models/place_models.dart'; import '../state/place_controller.dart'; const _mapboxAccessToken = String.fromEnvironment('MAPBOX_ACCESS_TOKEN'); const _mapboxStyle = String.fromEnvironment( 'MAPBOX_STYLE', defaultValue: 'mapbox/streets-v12', ); class MapflowShell extends ConsumerWidget { const MapflowShell({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final asyncState = ref.watch(placeControllerProvider); return asyncState.when( data: (state) => state.hasTelegramAuth ? _MapContent(state: state) : _TelegramLoginScreen( onAuthenticated: () => ref.invalidate(placeControllerProvider), ), loading: () => const _MapLoading(), error: (error, _) => _MapError(message: error.toString()), ); } } class _MapContent extends ConsumerWidget { const _MapContent({required this.state}); static const _center = LatLng(10.7718, 106.6982); final PlaceState state; @override Widget build(BuildContext context, WidgetRef ref) { final selected = state.selectedPlace; final availableTraits = { for (final place in state.recommendations) ...place.traits, }.toList(); return Scaffold( body: Stack( children: [ FlutterMap( options: MapOptions( initialCenter: selected?.coordinate ?? _center, initialZoom: 14.2, minZoom: 3, maxZoom: 18, ), children: [ const _BaseMapTileLayer(), 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 _MapAttribution(), ], ), SafeArea( child: Align( alignment: Alignment.topLeft, child: _UserAvatar(user: state.currentUser), ), ), if (availableTraits.isNotEmpty) SafeArea( child: Align( alignment: Alignment.topCenter, child: _TraitBar( selectedTrait: state.selectedTrait, traits: availableTraits, ), ), ), 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( fullscreenDialog: true, builder: (_) => AddExperienceFlow( coordinate: coordinate ?? _center, hasTelegramAuth: state.hasTelegramAuth, ), ), ); } } class _UserAvatar extends StatelessWidget { const _UserAvatar({required this.user}); final AppUser? user; @override Widget build(BuildContext context) { final photoUrl = user?.photoUrl; final fallback = _fallbackText(); return Padding( padding: const EdgeInsets.only(left: 12, top: 8), child: CircleAvatar( radius: 22, backgroundColor: const Color(0xFFFFFBF5), foregroundColor: const Color(0xFF17211D), backgroundImage: photoUrl == null ? null : NetworkImage(photoUrl), child: photoUrl == null ? Text( fallback, style: const TextStyle(fontWeight: FontWeight.w900), ) : null, ), ); } String _fallbackText() { final firstName = user?.firstName?.trim(); if (firstName != null && firstName.isNotEmpty) { return firstName.characters.first.toUpperCase(); } final username = user?.username?.trim(); if (username != null && username.isNotEmpty) { return username.characters.first.toUpperCase(); } return 'M'; } } class _TelegramLoginScreen extends StatelessWidget { const _TelegramLoginScreen({required this.onAuthenticated}); final VoidCallback onAuthenticated; @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: Center( child: SizedBox( width: 320, child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( 'MapFlow', style: Theme.of(context).textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.w900, letterSpacing: 0, ), ), const SizedBox(height: 24), TelegramLoginButton(onAuthenticated: onAuthenticated), ], ), ), ), ), ); } } 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: [const _BaseMapTileLayer(), const _MapAttribution()], ), 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: [const _BaseMapTileLayer(), const _MapAttribution()], ), 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 _BaseMapTileLayer extends StatelessWidget { const _BaseMapTileLayer(); static const _osmUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; @override Widget build(BuildContext context) { if (_mapboxAccessToken.isEmpty) { return TileLayer( urlTemplate: _osmUrl, userAgentPackageName: 'com.mapflow.app', ); } return TileLayer( urlTemplate: 'https://api.mapbox.com/styles/v1/$_mapboxStyle/tiles/512/{z}/{x}/{y}@2x' '?access_token=$_mapboxAccessToken', tileDimension: 512, zoomOffset: -1, maxNativeZoom: 22, userAgentPackageName: 'com.mapflow.app', ); } } class _MapAttribution extends StatelessWidget { const _MapAttribution(); @override Widget build(BuildContext context) { if (_mapboxAccessToken.isEmpty) { return const RichAttributionWidget( attributions: [TextSourceAttribution('OpenStreetMap contributors')], ); } return const RichAttributionWidget( attributions: [ TextSourceAttribution('Mapbox', prependCopyright: false), TextSourceAttribution('OpenStreetMap contributors'), ], ); } } class _TraitBar extends ConsumerWidget { const _TraitBar({required this.selectedTrait, required this.traits}); final PlaceTrait selectedTrait; final List traits; @override Widget build(BuildContext context, WidgetRef ref) { final controller = ref.read(placeControllerProvider.notifier); return SizedBox( height: 54, child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.fromLTRB(68, 8, 12, 0), itemCount: traits.length, separatorBuilder: (_, _) => const SizedBox(width: 6), itemBuilder: (context, index) { final item = traits[index]; return ChoiceChip( avatar: Icon(item.icon, size: 17), label: Text(item.label), selected: item == selectedTrait, onSelected: (_) => controller.selectTrait(item), backgroundColor: const Color(0xFFFFFBF5), selectedColor: Theme.of(context).colorScheme.primaryContainer, side: BorderSide.none, shape: const StadiumBorder(), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), ); }, ), ); } } 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 places; final ValueChanged onSelect; @override Widget build(BuildContext context) { if (places.isEmpty) { return const SizedBox.shrink(); } 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: [ if (place.photoUrls.isEmpty) const ColoredBox( color: Color(0xFF0F766E), child: Icon(Icons.place_outlined, color: Colors.white), ) else PageView.builder( itemCount: place.photoUrls.length, itemBuilder: (context, index) { return Image.network( place.photoUrls[index], fit: BoxFit.cover, errorBuilder: (_, _, _) => Container( color: const Color(0xFFE0D8CA), child: const Icon(Icons.place_outlined), ), ); }, ), const DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( 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, required this.hasTelegramAuth, }); final LatLng coordinate; final bool hasTelegramAuth; @override ConsumerState createState() => _AddExperienceFlowState(); } class _AddExperienceFlowState extends ConsumerState { static const _minimumVoiceSeconds = 30; Timer? _timer; late final TextEditingController _placeNameController; var _step = 0; var _seconds = 0; var _recording = false; var _submitting = false; PlaceRecommendation? _selectedPlace; @override void initState() { super.initState(); _placeNameController = TextEditingController(); } @override void dispose() { _timer?.cancel(); _placeNameController.dispose(); 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 places = ref.watch(placeControllerProvider).value?.places ?? []; 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: places, controller: _placeNameController, onSelect: (place) { setState(() { _selectedPlace = place; _placeNameController.text = place.name; _step = 2; }); controller.setReviewPlace(place.name); }, onManualNext: () { controller.setReviewPlace(_placeNameController.text); setState(() => _step = 2); }, ), _ => _VoiceStep( placeName: _placeNameController.text, hasTelegramAuth: widget.hasTelegramAuth, seconds: _seconds, minimumSeconds: _minimumVoiceSeconds, time: time, isRecording: _recording, isSubmitting: _submitting, canContinue: widget.hasTelegramAuth && _seconds >= _minimumVoiceSeconds, onToggleRecording: _toggleRecording, onNext: () async { setState(() => _submitting = true); final coordinate = _selectedPlace?.coordinate ?? widget.coordinate; controller.setReviewPlace(_placeNameController.text); await controller.publishReview(coordinate: coordinate); if (!context.mounted) { return; } Navigator.of(context).pop(); }, ), }; 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( 'Поделись ощущением от места голосом. Мы разберем запись через AI и удалим аудио после обработки.', textAlign: TextAlign.left, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w800, height: 1.18, letterSpacing: 0, ), ), ], ), action: FilledButton(onPressed: onNext, child: const Text('Далее')), ); } } class _PlaceStep extends StatelessWidget { const _PlaceStep({ required this.places, required this.controller, required this.onSelect, required this.onManualNext, }); final List places; final TextEditingController controller; final ValueChanged onSelect; final VoidCallback onManualNext; @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: 16), TextField( controller: controller, decoration: const InputDecoration(hintText: 'Название места'), textInputAction: TextInputAction.done, ), const SizedBox(height: 12), 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), ), ); }, ), ), ], ), action: FilledButton(onPressed: onManualNext, child: const Text('Далее')), ); } } class _VoiceStep extends StatelessWidget { const _VoiceStep({ required this.placeName, required this.hasTelegramAuth, required this.seconds, required this.minimumSeconds, required this.time, required this.isRecording, required this.isSubmitting, required this.canContinue, required this.onToggleRecording, required this.onNext, }); final String placeName; final bool hasTelegramAuth; final int seconds; final int minimumSeconds; final String time; final bool isRecording; final bool isSubmitting; final bool canContinue; final VoidCallback onToggleRecording; final VoidCallback onNext; @override Widget build(BuildContext context) { return _StepLayout( body: Column( children: [ const Spacer(), Text( placeName.trim().isEmpty ? 'Место' : placeName.trim(), textAlign: TextAlign.center, style: Theme.of( context, ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900), ), const SizedBox(height: 8), Text( hasTelegramAuth ? 'Минимум $minimumSeconds секунд' : 'Открой через Telegram', textAlign: TextAlign.center, ), const SizedBox(height: 26), SizedBox( width: 132, height: 132, child: FilledButton( onPressed: isSubmitting || !hasTelegramAuth ? null : 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 / minimumSeconds).clamp(0.0, 1.0), ), const Spacer(), ], ), action: FilledButton( onPressed: canContinue && !isSubmitting ? onNext : null, child: Text(isSubmitting ? 'Отправляем' : 'Далее'), ), ); } } 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: 'Закрыть', ), ], ); } }