From 56703c887f97453557b3df7c59ad15eaa7c093c8 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev <572431+veikab@users.noreply.github.com> Date: Fri, 8 May 2026 20:31:36 +0700 Subject: [PATCH] Require place before voice review --- lib/screens/mapflow_shell.dart | 150 +++++++++++++++++++++------------ 1 file changed, 94 insertions(+), 56 deletions(-) diff --git a/lib/screens/mapflow_shell.dart b/lib/screens/mapflow_shell.dart index 431cd02..4106245 100644 --- a/lib/screens/mapflow_shell.dart +++ b/lib/screens/mapflow_shell.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; @@ -719,7 +720,6 @@ class _AddExperienceFlowState extends ConsumerState { static const _minimumVoiceSeconds = 30; Timer? _timer; - late final TextEditingController _placeNameController; var _step = 0; var _seconds = 0; var _recording = false; @@ -729,13 +729,11 @@ class _AddExperienceFlowState extends ConsumerState { @override void initState() { super.initState(); - _placeNameController = TextEditingController(); } @override void dispose() { _timer?.cancel(); - _placeNameController.dispose(); super.dispose(); } @@ -743,6 +741,9 @@ class _AddExperienceFlowState extends ConsumerState { setState(() => _recording = !_recording); if (_recording) { _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) { + return; + } setState(() => _seconds += 1); ref .read(placeControllerProvider.notifier) @@ -753,47 +754,45 @@ class _AddExperienceFlowState extends ConsumerState { } } + String get _time => + '${(_seconds ~/ 60).toString().padLeft(2, '0')}:' + '${(_seconds % 60).toString().padLeft(2, '0')}'; + @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, + placeName: _selectedPlace?.name ?? '', hasTelegramAuth: widget.hasTelegramAuth, seconds: _seconds, minimumSeconds: _minimumVoiceSeconds, - time: time, + time: _time, isRecording: _recording, isSubmitting: _submitting, canContinue: widget.hasTelegramAuth && _seconds >= _minimumVoiceSeconds, onToggleRecording: _toggleRecording, onNext: () async { + final selectedPlace = _selectedPlace; + if (selectedPlace == null) { + return; + } setState(() => _submitting = true); - final coordinate = _selectedPlace?.coordinate ?? widget.coordinate; - controller.setReviewPlace(_placeNameController.text); - await controller.publishReview(coordinate: coordinate); + controller.setReviewPlace(selectedPlace.name); + await controller.publishReview(coordinate: selectedPlace.coordinate); if (!context.mounted) { return; } @@ -873,66 +872,56 @@ class _IntroStep extends StatelessWidget { } class _PlaceStep extends StatelessWidget { - const _PlaceStep({ - required this.places, - required this.controller, - required this.onSelect, - required this.onManualNext, - }); + const _PlaceStep({required this.places, required this.onSelect}); 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( - 'Место', + 'Выбери место рядом', + textAlign: TextAlign.center, 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, + child: places.isEmpty + ? const Center( + child: Icon(Icons.location_off_outlined, size: 42), + ) + : 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), + ), + ); + }, ), - 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('Далее')), ); } } @@ -983,6 +972,12 @@ class _VoiceStep extends StatelessWidget { textAlign: TextAlign.center, ), const SizedBox(height: 26), + if (isRecording || seconds > 0) ...[ + _VoiceWave(seconds: seconds, active: isRecording), + const SizedBox(height: 20), + ] else ...[ + const SizedBox(height: 70), + ], SizedBox( width: 132, height: 132, @@ -1016,6 +1011,49 @@ class _VoiceStep extends StatelessWidget { } } +class _VoiceWave extends StatelessWidget { + const _VoiceWave({required this.seconds, required this.active}); + + final int seconds; + final bool active; + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).colorScheme.primary; + return SizedBox( + height: 50, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + for (var index = 0; index < 23; index++) + AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + width: 4, + height: _barHeight(index), + margin: const EdgeInsets.symmetric(horizontal: 3), + decoration: BoxDecoration( + color: color.withValues(alpha: active ? 0.88 : 0.42), + borderRadius: BorderRadius.circular(8), + ), + ), + ], + ), + ); + } + + double _barHeight(int index) { + final phase = seconds * 0.72 + index * 0.58; + final height = 16.0 + (math.sin(phase).abs() * 28.0); + if (active) { + return height; + } + + return height * 0.55; + } +} + class _StepLayout extends StatelessWidget { const _StepLayout({required this.body, this.action});