Require place before voice review
All checks were successful
Build and deploy Flutter Web / build (push) Successful in 2m8s

This commit is contained in:
Ruslan Bakiev
2026-05-08 20:31:36 +07:00
parent 929d3a46d3
commit 56703c887f

View File

@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_map/flutter_map.dart';
@@ -719,7 +720,6 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
static const _minimumVoiceSeconds = 30; static const _minimumVoiceSeconds = 30;
Timer? _timer; Timer? _timer;
late final TextEditingController _placeNameController;
var _step = 0; var _step = 0;
var _seconds = 0; var _seconds = 0;
var _recording = false; var _recording = false;
@@ -729,13 +729,11 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_placeNameController = TextEditingController();
} }
@override @override
void dispose() { void dispose() {
_timer?.cancel(); _timer?.cancel();
_placeNameController.dispose();
super.dispose(); super.dispose();
} }
@@ -743,6 +741,9 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
setState(() => _recording = !_recording); setState(() => _recording = !_recording);
if (_recording) { if (_recording) {
_timer = Timer.periodic(const Duration(seconds: 1), (_) { _timer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) {
return;
}
setState(() => _seconds += 1); setState(() => _seconds += 1);
ref ref
.read(placeControllerProvider.notifier) .read(placeControllerProvider.notifier)
@@ -753,47 +754,45 @@ class _AddExperienceFlowState extends ConsumerState<AddExperienceFlow> {
} }
} }
String get _time =>
'${(_seconds ~/ 60).toString().padLeft(2, '0')}:'
'${(_seconds % 60).toString().padLeft(2, '0')}';
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final controller = ref.read(placeControllerProvider.notifier); final controller = ref.read(placeControllerProvider.notifier);
final places = ref.watch(placeControllerProvider).value?.places ?? []; 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) { final content = switch (_step) {
0 => _IntroStep(onNext: () => setState(() => _step = 1)), 0 => _IntroStep(onNext: () => setState(() => _step = 1)),
1 => _PlaceStep( 1 => _PlaceStep(
places: places, places: places,
controller: _placeNameController,
onSelect: (place) { onSelect: (place) {
setState(() { setState(() {
_selectedPlace = place; _selectedPlace = place;
_placeNameController.text = place.name;
_step = 2; _step = 2;
}); });
controller.setReviewPlace(place.name); controller.setReviewPlace(place.name);
}, },
onManualNext: () {
controller.setReviewPlace(_placeNameController.text);
setState(() => _step = 2);
},
), ),
_ => _VoiceStep( _ => _VoiceStep(
placeName: _placeNameController.text, placeName: _selectedPlace?.name ?? '',
hasTelegramAuth: widget.hasTelegramAuth, hasTelegramAuth: widget.hasTelegramAuth,
seconds: _seconds, seconds: _seconds,
minimumSeconds: _minimumVoiceSeconds, minimumSeconds: _minimumVoiceSeconds,
time: time, time: _time,
isRecording: _recording, isRecording: _recording,
isSubmitting: _submitting, isSubmitting: _submitting,
canContinue: widget.hasTelegramAuth && _seconds >= _minimumVoiceSeconds, canContinue: widget.hasTelegramAuth && _seconds >= _minimumVoiceSeconds,
onToggleRecording: _toggleRecording, onToggleRecording: _toggleRecording,
onNext: () async { onNext: () async {
final selectedPlace = _selectedPlace;
if (selectedPlace == null) {
return;
}
setState(() => _submitting = true); setState(() => _submitting = true);
final coordinate = _selectedPlace?.coordinate ?? widget.coordinate; controller.setReviewPlace(selectedPlace.name);
controller.setReviewPlace(_placeNameController.text); await controller.publishReview(coordinate: selectedPlace.coordinate);
await controller.publishReview(coordinate: coordinate);
if (!context.mounted) { if (!context.mounted) {
return; return;
} }
@@ -873,40 +872,31 @@ class _IntroStep extends StatelessWidget {
} }
class _PlaceStep extends StatelessWidget { class _PlaceStep extends StatelessWidget {
const _PlaceStep({ const _PlaceStep({required this.places, required this.onSelect});
required this.places,
required this.controller,
required this.onSelect,
required this.onManualNext,
});
final List<PlaceRecommendation> places; final List<PlaceRecommendation> places;
final TextEditingController controller;
final ValueChanged<PlaceRecommendation> onSelect; final ValueChanged<PlaceRecommendation> onSelect;
final VoidCallback onManualNext;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return _StepLayout( return _StepLayout(
body: Column( body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Место', 'Выбери место рядом',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
letterSpacing: 0, letterSpacing: 0,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextField(
controller: controller,
decoration: const InputDecoration(hintText: 'Название места'),
textInputAction: TextInputAction.done,
),
const SizedBox(height: 12),
Expanded( Expanded(
child: ListView.separated( child: places.isEmpty
? const Center(
child: Icon(Icons.location_off_outlined, size: 42),
)
: ListView.separated(
itemCount: places.length, itemCount: places.length,
separatorBuilder: (_, _) => const SizedBox(height: 10), separatorBuilder: (_, _) => const SizedBox(height: 10),
itemBuilder: (context, index) { itemBuilder: (context, index) {
@@ -932,7 +922,6 @@ class _PlaceStep extends StatelessWidget {
), ),
], ],
), ),
action: FilledButton(onPressed: onManualNext, child: const Text('Далее')),
); );
} }
} }
@@ -983,6 +972,12 @@ class _VoiceStep extends StatelessWidget {
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
const SizedBox(height: 26), const SizedBox(height: 26),
if (isRecording || seconds > 0) ...[
_VoiceWave(seconds: seconds, active: isRecording),
const SizedBox(height: 20),
] else ...[
const SizedBox(height: 70),
],
SizedBox( SizedBox(
width: 132, width: 132,
height: 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 { class _StepLayout extends StatelessWidget {
const _StepLayout({required this.body, this.action}); const _StepLayout({required this.body, this.action});