import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:latlong2/latlong.dart'; import '../models/experience_models.dart'; import '../state/experience_controller.dart'; class ExperienceMapScreen extends ConsumerWidget { const ExperienceMapScreen({super.key}); static const _initialCenter = LatLng(10.7718, 106.6982); @override Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(experienceControllerProvider); final selected = state.selectedExperience; return Scaffold( body: LayoutBuilder( builder: (context, constraints) { final wide = constraints.maxWidth >= 780; return Stack( children: [ FlutterMap( options: MapOptions( initialCenter: selected?.coordinate ?? _initialCenter, initialZoom: 14.2, minZoom: 3, maxZoom: 18, onLongPress: (_, point) => _showShareSheet(context, ref, point), ), children: [ TileLayer( urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'com.mapflow.app', ), MarkerLayer( markers: [ for (final experience in state.visibleExperiences) Marker( width: 54, height: 54, point: experience.coordinate, child: _ExperienceMarker( experience: experience, selected: selected?.id == experience.id, onTap: () => ref .read(experienceControllerProvider.notifier) .selectExperience(experience.id), ), ), ], ), const RichAttributionWidget( attributions: [ TextSourceAttribution('OpenStreetMap contributors'), ], ), ], ), SafeArea( child: Padding( padding: const EdgeInsets.all(14), child: Align( alignment: Alignment.topLeft, child: _TopPanel(state: state), ), ), ), if (wide) SafeArea( child: Align( alignment: Alignment.centerRight, child: Padding( padding: const EdgeInsets.all(18), child: SizedBox( width: 330, child: _ExperiencePanel( experience: selected, onShare: () => _showShareSheet( context, ref, selected?.coordinate ?? _initialCenter, ), ), ), ), ), ) else Align( alignment: Alignment.bottomCenter, child: _BottomExperiencePanel( experience: selected, onShare: () => _showShareSheet( context, ref, selected?.coordinate ?? _initialCenter, ), ), ), ], ); }, ), ); } void _showShareSheet(BuildContext context, WidgetRef ref, LatLng coordinate) { final placeController = TextEditingController(); final dishController = TextEditingController(); final contextController = TextEditingController(); var emotion = ExperienceEmotion.comfort; showModalBottomSheet( context: context, showDragHandle: true, isScrollControlled: true, builder: (sheetContext) { return StatefulBuilder( builder: (context, setState) { return Padding( padding: EdgeInsets.fromLTRB( 18, 8, 18, MediaQuery.viewInsetsOf(context).bottom + 18, ), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'Поделиться опытом', style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w800, ), ), const SizedBox(height: 14), TextField( controller: placeController, decoration: const InputDecoration(labelText: 'Место'), ), const SizedBox(height: 10), TextField( controller: dishController, decoration: const InputDecoration(labelText: 'Блюдо'), ), const SizedBox(height: 10), TextField( controller: contextController, minLines: 2, maxLines: 3, decoration: const InputDecoration(labelText: 'Контекст'), ), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, children: [ for (final item in ExperienceEmotion.values) ChoiceChip( label: Text(item.label), selected: emotion == item, onSelected: (_) => setState(() => emotion = item), ), ], ), const SizedBox(height: 16), SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: () { ref .read(experienceControllerProvider.notifier) .addExperience( placeName: placeController.text, dishName: dishController.text, emotion: emotion, coordinate: coordinate, context: contextController.text, ); Navigator.of(sheetContext).pop(); }, icon: const Icon(Icons.add_location_alt_outlined), label: const Text('Сохранить'), ), ), ], ), ), ); }, ); }, ).whenComplete(() { placeController.dispose(); dishController.dispose(); contextController.dispose(); }); } } class _TopPanel extends ConsumerWidget { const _TopPanel({required this.state}); final ExperienceState state; @override Widget build(BuildContext context, WidgetRef ref) { final controller = ref.read(experienceControllerProvider.notifier); return Material( color: const Color(0xFFFFFBF5), borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.all(12), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 560), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.map_outlined, size: 22), const SizedBox(width: 8), Text( 'MapFlow', style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w900, ), ), ], ), const SizedBox(height: 10), Wrap( spacing: 8, runSpacing: 8, children: [ FilterChip( label: const Text('все'), selected: state.filterEmotion == null, onSelected: (_) => controller.setEmotionFilter(null), ), for (final emotion in ExperienceEmotion.values) FilterChip( avatar: Icon(emotion.icon, size: 17), label: Text(emotion.label), selected: state.filterEmotion == emotion, onSelected: (_) => controller.setEmotionFilter(emotion), ), ], ), ], ), ), ), ); } } class _BottomExperiencePanel extends StatelessWidget { const _BottomExperiencePanel({ required this.experience, required this.onShare, }); final PlaceExperience? experience; final VoidCallback onShare; @override Widget build(BuildContext context) { return SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(12, 0, 12, 12), child: _ExperiencePanel(experience: experience, onShare: onShare), ), ); } } class _ExperiencePanel extends StatelessWidget { const _ExperiencePanel({required this.experience, required this.onShare}); final PlaceExperience? experience; final VoidCallback onShare; @override Widget build(BuildContext context) { final item = experience; return Material( color: const Color(0xFFFFFBF5), borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.all(14), child: item == null ? FilledButton.icon( onPressed: onShare, icon: const Icon(Icons.add_location_alt_outlined), label: const Text('Поделиться'), ) : Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ _EmotionBadge(emotion: item.emotion), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.placeName, style: Theme.of(context).textTheme.titleLarge ?.copyWith(fontWeight: FontWeight.w900), ), Text(item.neighborhood), ], ), ), ], ), const SizedBox(height: 12), Text( item.dish.name, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w800, ), ), const SizedBox(height: 4), Text(item.dish.reason), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ _TinyPill(text: item.dish.texture), _TinyPill(text: item.context), _TinyPill(text: item.createdLabel), ], ), const SizedBox(height: 14), _AuthorBlock(author: item.author), const SizedBox(height: 14), SizedBox( width: double.infinity, child: FilledButton.icon( onPressed: onShare, icon: const Icon(Icons.add_location_alt_outlined), label: const Text('Поделиться рядом'), ), ), ], ), ), ); } } class _ExperienceMarker extends StatelessWidget { const _ExperienceMarker({ required this.experience, required this.selected, required this.onTap, }); final PlaceExperience experience; final bool selected; final VoidCallback onTap; @override Widget build(BuildContext context) { final color = _emotionColor(experience.emotion); return GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 180), decoration: BoxDecoration( color: selected ? color : Colors.white, shape: BoxShape.circle, border: Border.all(color: color, width: selected ? 3 : 2), boxShadow: const [ BoxShadow( color: Color(0x33000000), blurRadius: 14, offset: Offset(0, 8), ), ], ), child: Icon( experience.emotion.icon, color: selected ? Colors.white : color, size: 24, ), ), ); } } class _EmotionBadge extends StatelessWidget { const _EmotionBadge({required this.emotion}); final ExperienceEmotion emotion; @override Widget build(BuildContext context) { final color = _emotionColor(emotion); return Container( width: 46, height: 46, decoration: BoxDecoration( color: color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8), ), child: Icon(emotion.icon, color: color), ); } } class _AuthorBlock extends StatelessWidget { const _AuthorBlock({required this.author}); final ExperienceAuthor author; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( author.name, style: Theme.of( context, ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800), ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ for (final facet in author.facets) _TinyPill(text: '${facet.name}: ${facet.value}'), ], ), ], ); } } class _TinyPill extends StatelessWidget { const _TinyPill({required this.text}); final String text; @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: BoxDecoration( color: const Color(0xFFF0E8DA), borderRadius: BorderRadius.circular(999), ), child: Text( text, style: Theme.of( context, ).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700), ), ); } } Color _emotionColor(ExperienceEmotion emotion) { return switch (emotion) { ExperienceEmotion.comfort => const Color(0xFF0F766E), ExperienceEmotion.energy => const Color(0xFFE11D48), ExperienceEmotion.curiosity => const Color(0xFF7C3AED), ExperienceEmotion.tenderness => const Color(0xFFDB2777), ExperienceEmotion.focus => const Color(0xFF2563EB), }; }