Initial Flutter app

This commit is contained in:
Ruslan Bakiev
2026-05-05 11:58:38 +07:00
commit 80b8f1cc18
112 changed files with 5732 additions and 0 deletions

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