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

496 lines
16 KiB
Dart

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