import 'dart:async'; import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:latlong2/latlong.dart' hide Path; import 'package:record/record.dart'; import '../api/mapflow_api.dart'; import '../auth/telegram_login_button.dart'; import '../auth/telegram_session.dart' as telegram_session; import '../audio/browser_voice_meter.dart'; import '../models/place_models.dart'; import '../state/place_controller.dart'; const _mapboxAccessToken = String.fromEnvironment('MAPBOX_ACCESS_TOKEN'); const _mapboxStyle = String.fromEnvironment( 'MAPBOX_STYLE', defaultValue: 'mapbox/streets-v12', ); class MapflowShell extends ConsumerWidget { const MapflowShell({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final asyncState = ref.watch(placeControllerProvider); return asyncState.when( data: (state) => state.hasTelegramAuth ? _MapContent(state: state) : _TelegramLoginScreen( onAuthenticated: () => ref.invalidate(placeControllerProvider), ), loading: () => const _MapLoading(), error: (error, _) => _MapError(message: error.toString()), ); } } class _MapContent extends ConsumerWidget { const _MapContent({required this.state}); static const _fallbackCenter = LatLng(10.7718, 106.6982); final PlaceState state; @override Widget build(BuildContext context, WidgetRef ref) { final selected = state.selectedPlace; final userCoordinate = state.userCoordinate; final mapCenter = userCoordinate ?? selected?.coordinate ?? _fallbackCenter; final availableTraits = { for (final place in state.recommendations) ...place.traits, }.toList(); return Scaffold( body: Stack( children: [ FlutterMap( options: MapOptions( initialCenter: mapCenter, initialZoom: 14.2, minZoom: 3, maxZoom: 18, ), children: [ const _BaseMapTileLayer(), 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), ), ), if (userCoordinate != null) Marker( width: 30, height: 30, point: userCoordinate, child: const _UserLocationMarker(), ), ], ), const _MapAttribution(), ], ), SafeArea( child: Align( alignment: Alignment.topLeft, child: _UserAvatar( user: state.currentUser, onLogout: () { telegram_session.clearMapflowSession(); ref.invalidate(placeControllerProvider); telegram_session.reloadApp(); }, ), ), ), if (availableTraits.isNotEmpty) SafeArea( child: Align( alignment: Alignment.topCenter, child: _TraitBar( selectedTrait: state.selectedTrait, traits: availableTraits, ), ), ), 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, userCoordinate ?? selected?.coordinate, ), child: const Icon(Icons.add_location_alt_outlined), ), ), ), ), ], ), ); } void _openAddFlow(BuildContext context, LatLng? coordinate) { Navigator.of(context).push( MaterialPageRoute( fullscreenDialog: true, builder: (_) => AddExperienceFlow( coordinate: coordinate, hasTelegramAuth: state.hasTelegramAuth, ), ), ); } } class _UserLocationMarker extends StatelessWidget { const _UserLocationMarker(); @override Widget build(BuildContext context) { final color = Theme.of(context).colorScheme.primary; return DecoratedBox( decoration: BoxDecoration( shape: BoxShape.circle, color: color.withValues(alpha: 0.18), ), child: Center( child: Container( width: 14, height: 14, decoration: BoxDecoration( shape: BoxShape.circle, color: color, border: Border.all(color: Colors.white, width: 3), ), ), ), ); } } class _UserAvatar extends StatelessWidget { const _UserAvatar({required this.user, required this.onLogout}); final AppUser? user; final VoidCallback onLogout; @override Widget build(BuildContext context) { final photoUrl = user?.photoUrl; final imageUrl = photoUrl == null || photoUrl.isEmpty ? null : _avatarImageUrl(photoUrl); final fallback = _fallbackText(); return Padding( padding: const EdgeInsets.only(left: 12, top: 8), child: PopupMenuButton<_AvatarAction>( tooltip: '', offset: const Offset(0, 8), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), onSelected: (action) { switch (action) { case _AvatarAction.logout: onLogout(); } }, itemBuilder: (_) => const [ PopupMenuItem<_AvatarAction>( value: _AvatarAction.logout, child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.logout, size: 18), SizedBox(width: 10), Text('Выйти'), ], ), ), ], child: ClipOval( child: SizedBox.square( dimension: 44, child: imageUrl == null ? _AvatarFallback(text: fallback) : Image.network( imageUrl, fit: BoxFit.cover, errorBuilder: (_, _, _) => _AvatarFallback(text: fallback), ), ), ), ), ); } String _fallbackText() { final firstName = user?.firstName?.trim(); if (firstName != null && firstName.isNotEmpty) { return firstName.characters.first.toUpperCase(); } final username = user?.username?.trim(); if (username != null && username.isNotEmpty) { return username.characters.first.toUpperCase(); } return 'M'; } String _avatarImageUrl(String photoUrl) { final separator = photoUrl.contains('?') ? '&' : '?'; return '$photoUrl${separator}v=2'; } } enum _AvatarAction { logout } class _AvatarFallback extends StatelessWidget { const _AvatarFallback({required this.text}); final String text; @override Widget build(BuildContext context) { return ColoredBox( color: const Color(0xFFFFFBF5), child: Center( child: Text( text, style: const TextStyle( color: Color(0xFF17211D), fontWeight: FontWeight.w900, ), ), ), ); } } class _TelegramLoginScreen extends StatefulWidget { const _TelegramLoginScreen({required this.onAuthenticated}); final VoidCallback onAuthenticated; @override State<_TelegramLoginScreen> createState() => _TelegramLoginScreenState(); } class _TelegramLoginScreenState extends State<_TelegramLoginScreen> { final _api = MapflowApi(); Timer? _pollTimer; Timer? _countdownTimer; DateTime? _loginExpiresAt; var _loading = false; var _message = ''; @override void initState() { super.initState(); final urlToken = telegram_session.telegramLoginTokenFromUrl(); final pendingToken = urlToken.isNotEmpty ? urlToken : telegram_session.pendingTelegramLoginToken(); if (pendingToken.isNotEmpty) { telegram_session.savePendingTelegramLoginToken(pendingToken); _pollLogin(pendingToken); _pollTimer = Timer.periodic( const Duration(seconds: 2), (_) => _pollLogin(pendingToken), ); } } @override void dispose() { _pollTimer?.cancel(); _countdownTimer?.cancel(); super.dispose(); } Future _startLogin() async { setState(() { _loading = true; _message = ''; }); final login = await _api.startTelegramBotLogin(); telegram_session.savePendingTelegramLoginToken(login.token); telegram_session.openExternalUrl(login.botUrl); _pollTimer?.cancel(); _countdownTimer?.cancel(); _pollTimer = Timer.periodic( const Duration(seconds: 2), (_) => _pollLogin(login.token), ); _countdownTimer = Timer.periodic(const Duration(seconds: 1), (_) { if (mounted) { setState(() {}); } }); setState(() { _loading = false; _loginExpiresAt = login.expiresAt; _message = ''; }); } Future _pollLogin(String token) async { final status = await _api.fetchTelegramBotLoginStatus(token); if (status.isExpired) { _pollTimer?.cancel(); _countdownTimer?.cancel(); telegram_session.clearPendingTelegramLoginToken(); if (!mounted) { return; } _loginExpiresAt = null; setState(() => _message = 'Ссылка устарела. Запусти вход заново.'); return; } if (!status.isConfirmed) { return; } _pollTimer?.cancel(); _countdownTimer?.cancel(); telegram_session.saveMapflowSessionToken(status.sessionToken!); telegram_session.clearPendingTelegramLoginToken(); widget.onAuthenticated(); telegram_session.reloadApp(); } String get _remainingText { final expiresAt = _loginExpiresAt; if (expiresAt == null) { return ''; } final seconds = expiresAt.difference(DateTime.now()).inSeconds; if (seconds <= 0) { return '00:00'; } final minutes = seconds ~/ 60; final rest = (seconds % 60).toString().padLeft(2, '0'); return '$minutes:$rest'; } @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: Center( child: SizedBox( width: 320, child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( 'MapFlow', style: Theme.of(context).textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.w900, letterSpacing: 0, ), ), const SizedBox(height: 24), TelegramLoginButton(onPressed: _startLogin, loading: _loading), if (_remainingText.isNotEmpty) ...[ const SizedBox(height: 12), Text( _remainingText, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w800, letterSpacing: 0, ), ), ], if (_message.isNotEmpty) ...[ const SizedBox(height: 14), Text( _message, textAlign: TextAlign.center, style: Theme.of(context).textTheme.bodyMedium, ), ], ], ), ), ), ), ); } } class _MapLoading extends StatelessWidget { const _MapLoading(); @override Widget build(BuildContext context) { return const Scaffold(body: Center(child: CircularProgressIndicator())); } } class _MapError extends StatelessWidget { const _MapError({required this.message}); final String message; @override Widget build(BuildContext context) { return Scaffold( body: Stack( children: [ FlutterMap( options: const MapOptions( initialCenter: LatLng(10.7718, 106.6982), initialZoom: 14.2, ), children: [const _BaseMapTileLayer(), const _MapAttribution()], ), SafeArea( child: Align( alignment: Alignment.bottomCenter, child: Container( margin: const EdgeInsets.all(12), padding: const EdgeInsets.all(12), color: const Color(0xFFFFFBF5), child: Text( message, maxLines: 3, overflow: TextOverflow.ellipsis, ), ), ), ), ], ), ); } } class _BaseMapTileLayer extends StatelessWidget { const _BaseMapTileLayer(); static const _osmUrl = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; @override Widget build(BuildContext context) { if (_mapboxAccessToken.isEmpty) { return TileLayer( urlTemplate: _osmUrl, userAgentPackageName: 'com.mapflow.app', ); } return TileLayer( urlTemplate: 'https://api.mapbox.com/styles/v1/$_mapboxStyle/tiles/512/{z}/{x}/{y}@2x' '?access_token=$_mapboxAccessToken', tileDimension: 512, zoomOffset: -1, maxNativeZoom: 22, userAgentPackageName: 'com.mapflow.app', ); } } class _MapAttribution extends StatelessWidget { const _MapAttribution(); @override Widget build(BuildContext context) { if (_mapboxAccessToken.isEmpty) { return const RichAttributionWidget( attributions: [TextSourceAttribution('OpenStreetMap contributors')], ); } return const RichAttributionWidget( attributions: [ TextSourceAttribution('Mapbox', prependCopyright: false), TextSourceAttribution('OpenStreetMap contributors'), ], ); } } class _TraitBar extends ConsumerWidget { const _TraitBar({required this.selectedTrait, required this.traits}); final PlaceTrait selectedTrait; final List traits; @override Widget build(BuildContext context, WidgetRef ref) { final controller = ref.read(placeControllerProvider.notifier); return SizedBox( height: 54, child: ListView.separated( scrollDirection: Axis.horizontal, padding: const EdgeInsets.fromLTRB(68, 8, 12, 0), itemCount: traits.length, separatorBuilder: (_, _) => const SizedBox(width: 6), itemBuilder: (context, index) { final item = traits[index]; return ChoiceChip( avatar: Icon(item.icon, size: 17), label: Text(item.label), selected: item == selectedTrait, onSelected: (_) => controller.selectTrait(item), backgroundColor: const Color(0xFFFFFBF5), selectedColor: Theme.of(context).colorScheme.primaryContainer, side: BorderSide.none, shape: const StadiumBorder(), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9), ); }, ), ); } } 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 places; final ValueChanged onSelect; @override Widget build(BuildContext context) { if (places.isEmpty) { return const SizedBox.shrink(); } 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: [ if (place.photoUrls.isEmpty) const ColoredBox( color: Color(0xFF0F766E), child: Icon(Icons.place_outlined, color: Colors.white), ) else 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, required this.hasTelegramAuth, }); final LatLng? coordinate; final bool hasTelegramAuth; @override ConsumerState createState() => _AddExperienceFlowState(); } class _AddExperienceFlowState extends ConsumerState { static const _minimumInformationUnits = 8.0; static const _nearbyPlaceRadiusMeters = 200; final _api = MapflowApi(); final _recorder = AudioRecorder(); final _browserVoiceMeter = BrowserVoiceMeter(); final _waveSamples = List.filled(160, 0.0); final _visualRandom = math.Random(); Future>? _nearbyPlacesFuture; Timer? _timer; Timer? _visualTimer; Timer? _amplitudeTimer; StreamSubscription? _audioStreamSub; var _step = 0; var _seconds = 0; var _informationUnits = 0.0; var _recording = false; var _submitting = false; var _micAllowed = true; var _usingBrowserVoiceMeter = false; var _ambientLevel = 0.008; var _voiceCeiling = 0.045; var _noiseDb = -72.0; var _voicePeakDb = -34.0; var _liveLevel = 0.0; var _visualPhase = 0.0; DateTime? _lastAudioChunkAt; DateTime? _lastInformationAt; @override void initState() { super.initState(); } @override void dispose() { _timer?.cancel(); _visualTimer?.cancel(); _amplitudeTimer?.cancel(); _audioStreamSub?.cancel(); _browserVoiceMeter.dispose(); _recorder.dispose(); super.dispose(); } Future _toggleRecording() async { if (_recording) { await _stopRecording(); return; } await _startRecording(); } Future> _loadNearbyPlaces() async { final coordinate = widget.coordinate; if (coordinate == null) { return const []; } return _api.fetchNearbyPlaces( coordinate: coordinate, radiusMeters: _nearbyPlaceRadiusMeters, ); } Future _startRecording() async { if (_browserVoiceMeter.isSupported) { await _browserVoiceMeter.start(_handleBrowserVoiceFrame); _lastAudioChunkAt = DateTime.now(); _lastInformationAt = _lastAudioChunkAt; _startIdleWave(); _startDurationTimer(); setState(() { _micAllowed = true; _recording = true; _usingBrowserVoiceMeter = true; _liveLevel = 0; _informationUnits = 0; }); return; } final hasPermission = await _recorder.hasPermission(); if (!hasPermission) { setState(() => _micAllowed = false); return; } final stream = await _recorder.startStream( const RecordConfig( encoder: AudioEncoder.pcm16bits, sampleRate: 44100, numChannels: 1, echoCancel: true, noiseSuppress: true, autoGain: true, ), ); _audioStreamSub = stream.listen(_handleAudioChunk); _lastAudioChunkAt = DateTime.now(); _lastInformationAt = _lastAudioChunkAt; _startIdleWave(); _startAmplitudePolling(); _startDurationTimer(); setState(() { _micAllowed = true; _recording = true; _usingBrowserVoiceMeter = false; _liveLevel = 0; _informationUnits = 0; }); } Future _stopRecording() async { _timer?.cancel(); _visualTimer?.cancel(); _amplitudeTimer?.cancel(); if (_usingBrowserVoiceMeter) { await _browserVoiceMeter.stop(); } else { await _audioStreamSub?.cancel(); _audioStreamSub = null; await _recorder.stop(); } _lastAudioChunkAt = null; _lastInformationAt = null; if (!mounted) { return; } setState(() { _recording = false; _usingBrowserVoiceMeter = false; _liveLevel = 0; }); } void _startDurationTimer() { _timer?.cancel(); _timer = Timer.periodic(const Duration(seconds: 1), (_) { if (!mounted) { return; } setState(() => _seconds += 1); ref .read(placeControllerProvider.notifier) .setReviewDuration(Duration(seconds: _seconds)); }); } void _startIdleWave() { _visualTimer?.cancel(); _visualTimer = Timer.periodic(const Duration(milliseconds: 70), (_) { if (!mounted || !_recording) { return; } final lastSignalAt = _lastAudioChunkAt; final hasFreshSignal = lastSignalAt != null && DateTime.now().difference(lastSignalAt).inMilliseconds < 220; if (hasFreshSignal) { return; } _visualPhase += 0.24; final idleSamples = List.generate(8, (index) { final wave = math.sin(_visualPhase + index * 0.72) * 0.5 + 0.5; final noise = _visualRandom.nextDouble() * 0.16; return (0.10 + wave * 0.18 + noise).clamp(0.0, 1.0); }); setState(() { _waveSamples ..removeRange(0, idleSamples.length) ..addAll(idleSamples); }); }); } void _startAmplitudePolling() { _amplitudeTimer?.cancel(); _amplitudeTimer = Timer.periodic(const Duration(milliseconds: 90), ( _, ) async { if (!mounted || !_recording) { return; } final amplitude = await _recorder.getAmplitude(); if (!mounted || !_recording) { return; } _handleAmplitude(amplitude.current); }); } void _handleAmplitude(double currentDb) { final now = DateTime.now(); final level = _normalizeDbLevel(currentDb); final informationDelta = _consumeInformationDelta(level, now); _visualPhase += 0.38; final samples = List.generate(8, (index) { final wave = math.sin(_visualPhase + index * 0.68) * 0.5 + 0.5; return (0.08 + level * 0.70 + wave * level * 0.24).clamp(0.0, 1.0); }); setState(() { _waveSamples ..removeRange(0, samples.length) ..addAll(samples); _liveLevel = _smoothLevel(_liveLevel, level); _informationUnits = math.min( _minimumInformationUnits, _informationUnits + informationDelta, ); }); } void _handleBrowserVoiceFrame(VoiceMeterFrame frame) { if (!mounted || !_recording) { return; } final now = DateTime.now(); final informationDelta = _consumeInformationDelta(frame.level, now); _lastAudioChunkAt = now; setState(() { if (frame.samples.isNotEmpty) { _waveSamples ..removeRange(0, frame.samples.length) ..addAll(frame.samples); } _liveLevel = _smoothLevel(_liveLevel, frame.level); _informationUnits = math.min( _minimumInformationUnits, _informationUnits + informationDelta, ); }); } double _normalizeDbLevel(double currentDb) { final db = currentDb.clamp(-160.0, 0.0); if (db < _noiseDb) { _noiseDb = _noiseDb * 0.90 + db * 0.10; } else { _noiseDb = _noiseDb * 0.995 + db * 0.005; } if (db > _voicePeakDb) { _voicePeakDb = _voicePeakDb * 0.72 + db * 0.28; } else { _voicePeakDb = math.max(_noiseDb + 18, _voicePeakDb * 0.998 + db * 0.002); } final range = math.max(18.0, _voicePeakDb - _noiseDb); final gated = ((db - _noiseDb - 5) / range).clamp(0.0, 1.0); return math.pow(gated, 0.62).toDouble(); } double _smoothLevel(double current, double next) { final weight = next > current ? 0.46 : 0.18; return current + (next - current) * weight; } double _consumeInformationDelta(double voicedAmount, DateTime now) { final previous = _lastInformationAt ?? now; _lastInformationAt = now; final deltaSeconds = now.difference(previous).inMilliseconds.clamp(20, 180) / 1000; return voicedAmount.clamp(0.0, 1.0) * deltaSeconds; } void _handleAudioChunk(Uint8List chunk) { if (chunk.length < 2) { return; } final bytes = ByteData.sublistView(chunk); final sampleCount = chunk.length ~/ 2; final bucketCount = 12.clamp(1, sampleCount); final bucketSize = (sampleCount / bucketCount).ceil(); final peaks = []; var totalRms = 0.0; for (var bucket = 0; bucket < bucketCount; bucket++) { final startSample = bucket * bucketSize; final endSample = math.min(startSample + bucketSize, sampleCount); var peak = 0.0; var sumSquares = 0.0; var bucketSamples = 0; for ( var sampleIndex = startSample; sampleIndex < endSample; sampleIndex++ ) { final sample = bytes.getInt16(sampleIndex * 2, Endian.little) / 32768.0; final absoluteSample = sample.abs(); peak = math.max(peak, absoluteSample); sumSquares += absoluteSample * absoluteSample; bucketSamples += 1; } final rms = bucketSamples == 0 ? 0.0 : math.sqrt(sumSquares / bucketSamples); totalRms += rms; peaks.add(math.max(peak, rms * 2.6)); } if (peaks.isEmpty) { return; } final now = DateTime.now(); final previousChunkAt = _lastAudioChunkAt ?? now; _lastAudioChunkAt = now; final currentRms = totalRms / peaks.length; _ambientLevel = currentRms < _ambientLevel ? (_ambientLevel * 0.86 + currentRms * 0.14) : (_ambientLevel * 0.985 + currentRms * 0.015); _voiceCeiling = currentRms > _voiceCeiling ? (_voiceCeiling * 0.68 + currentRms * 0.32) : math.max(_ambientLevel + 0.018, _voiceCeiling * 0.992); final dynamicRange = math.max(0.012, _voiceCeiling - _ambientLevel); final normalizedPeaks = peaks .map((peak) => ((peak - _ambientLevel) / dynamicRange).clamp(0.0, 1.0)) .map((peak) => math.pow(peak, 0.62).toDouble()) .toList(); final voicedAmount = normalizedPeaks.fold(0, (sum, value) => sum + value) / normalizedPeaks.length; final hasRecentAmplitude = now.difference(previousChunkAt).inMilliseconds < 120; final informationDelta = hasRecentAmplitude ? 0.0 : _consumeInformationDelta(voicedAmount, now); if (!mounted) { return; } setState(() { _waveSamples ..removeRange(0, normalizedPeaks.length) ..addAll( normalizedPeaks.map((peak) { final visibleSignal = 0.12 + peak * 0.88; return visibleSignal.clamp(0.0, 1.0); }), ); _liveLevel = _smoothLevel(_liveLevel, voicedAmount); _informationUnits = math.min( _minimumInformationUnits, _informationUnits + informationDelta, ); }); } @override Widget build(BuildContext context) { final controller = ref.read(placeControllerProvider.notifier); final informationProgress = (_informationUnits / _minimumInformationUnits) .clamp(0.0, 1.0); final content = switch (_step) { 0 => _IntroStep(onNext: () => setState(() => _step = 1)), 1 => _VoiceStep( placeName: '', hasTelegramAuth: widget.hasTelegramAuth, informationProgress: informationProgress, isRecording: _recording, isSubmitting: _submitting, micAllowed: _micAllowed, samples: _waveSamples, liveLevel: _liveLevel, canContinue: widget.hasTelegramAuth && informationProgress >= 1, onToggleRecording: _toggleRecording, onNext: () async { if (_recording) { await _stopRecording(); } setState(() { _nearbyPlacesFuture = _loadNearbyPlaces(); _step = 2; }); }, ), _ => _PlaceStep( placesFuture: _nearbyPlacesFuture, radiusMeters: _nearbyPlaceRadiusMeters, isSubmitting: _submitting, onSelect: (place) async { setState(() => _submitting = true); controller.setReviewPlace(place.name); await controller.publishReview(place: place); if (!context.mounted) { return; } Navigator.of(context).pop(); }, ), }; final isVoiceStep = _step == 1; return Scaffold( backgroundColor: isVoiceStep ? const Color(0xFF05030B) : 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, dark: isVoiceStep, 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( 'Поделись ощущением от места голосом. Мы разберем запись через AI и удалим аудио после обработки.', textAlign: TextAlign.left, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w800, height: 1.18, letterSpacing: 0, ), ), ], ), action: FilledButton(onPressed: onNext, child: const Text('Далее')), ); } } class _PlaceStep extends StatelessWidget { const _PlaceStep({ required this.placesFuture, required this.radiusMeters, required this.isSubmitting, required this.onSelect, }); final Future>? placesFuture; final int radiusMeters; final bool isSubmitting; final Future Function(PlaceRecommendation) onSelect; @override Widget build(BuildContext context) { return _StepLayout( body: Column( children: [ Text( 'Выбери место рядом', textAlign: TextAlign.center, style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w900, letterSpacing: 0, ), ), const SizedBox(height: 16), Expanded( child: FutureBuilder>( future: placesFuture, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return const Center( child: Icon(Icons.error_outline, size: 42), ); } final places = snapshot.data ?? const []; if (places.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.location_off_outlined, size: 42), const SizedBox(height: 10), Text('Нет мест в $radiusMetersм'), ], ), ); } return ListView.separated( itemCount: places.length, separatorBuilder: (_, _) => const SizedBox(height: 10), itemBuilder: (context, index) { final place = places[index]; return ListTile( onTap: isSubmitting ? null : () => 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), ), ); }, ); }, ), ), ], ), ); } } class _VoiceStep extends StatelessWidget { const _VoiceStep({ required this.placeName, required this.hasTelegramAuth, required this.informationProgress, required this.isRecording, required this.isSubmitting, required this.micAllowed, required this.samples, required this.liveLevel, required this.canContinue, required this.onToggleRecording, required this.onNext, }); final String placeName; final bool hasTelegramAuth; final double informationProgress; final bool isRecording; final bool isSubmitting; final bool micAllowed; final List samples; final double liveLevel; final bool canContinue; final Future Function() onToggleRecording; final VoidCallback onNext; @override Widget build(BuildContext context) { final showNext = canContinue && !isRecording; return Column( children: [ if (placeName.trim().isNotEmpty) ...[ Text( placeName.trim(), maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: Theme.of( context, ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900), ), const SizedBox(height: 10), ], Expanded( child: Center( child: _VoiceInformationField( samples: samples, active: isRecording, progress: informationProgress, liveLevel: liveLevel, ), ), ), if (!micAllowed) const Padding( padding: EdgeInsets.only(bottom: 10), child: Icon( Icons.mic_off_outlined, color: Color(0xFFFF7A90), size: 22, ), ), AnimatedSwitcher( duration: const Duration(milliseconds: 180), child: showNext ? Padding( key: const ValueKey('next'), padding: const EdgeInsets.only(bottom: 14), child: FilledButton( style: FilledButton.styleFrom( backgroundColor: Colors.white, foregroundColor: const Color(0xFF090613), ), onPressed: isSubmitting ? null : onNext, child: Text(isSubmitting ? 'Отправляем' : 'Далее'), ), ) : const SizedBox.shrink(key: ValueKey('empty-next')), ), _VoiceRecordButton( progress: informationProgress, liveLevel: liveLevel, isRecording: isRecording, enabled: hasTelegramAuth && !isSubmitting, onPressed: onToggleRecording, ), ], ); } } class _VoiceRecordButton extends StatefulWidget { const _VoiceRecordButton({ required this.progress, required this.liveLevel, required this.isRecording, required this.enabled, required this.onPressed, }); final double progress; final double liveLevel; final bool isRecording; final bool enabled; final Future Function() onPressed; @override State<_VoiceRecordButton> createState() => _VoiceRecordButtonState(); } class _VoiceRecordButtonState extends State<_VoiceRecordButton> with SingleTickerProviderStateMixin { late final AnimationController _pulseController; @override void initState() { super.initState(); _pulseController = AnimationController( vsync: this, duration: const Duration(milliseconds: 1400), ); _syncPulse(); } @override void didUpdateWidget(covariant _VoiceRecordButton oldWidget) { super.didUpdateWidget(oldWidget); _syncPulse(); } @override void dispose() { _pulseController.dispose(); super.dispose(); } void _syncPulse() { if (widget.isRecording && !_pulseController.isAnimating) { _pulseController.repeat(); } if (!widget.isRecording && _pulseController.isAnimating) { _pulseController.stop(); _pulseController.value = 0; } } @override Widget build(BuildContext context) { return SizedBox( width: 164, height: 164, child: AnimatedBuilder( animation: _pulseController, builder: (context, child) { final pulse = widget.isRecording ? _pulseController.value : 0.0; final level = widget.isRecording ? widget.liveLevel : 0.0; return Stack( alignment: Alignment.center, children: [ for (final offset in const [0.0, 0.32, 0.64]) Transform.scale( scale: 1 + ((pulse + offset) % 1) * (0.10 + level * 0.32) + level * 0.10, child: Container( width: 130, height: 130, decoration: BoxDecoration( shape: BoxShape.circle, border: Border.all( color: const Color(0xFFFF2D75).withValues( alpha: widget.isRecording ? ((0.06 + level * 0.26) * (1 - ((pulse + offset) % 1))) : 0, ), width: 2.5 + level * 2.5, ), ), ), ), SizedBox( width: 154, height: 154, child: CircularProgressIndicator( value: widget.progress, strokeWidth: 7, strokeCap: StrokeCap.round, color: const Color(0xFFFF2D75), backgroundColor: Colors.white.withValues(alpha: 0.12), ), ), child!, ], ); }, child: SizedBox( width: 120, height: 120, child: FilledButton( onPressed: widget.enabled ? () => widget.onPressed() : null, style: FilledButton.styleFrom( backgroundColor: Colors.white, foregroundColor: const Color(0xFF090613), disabledBackgroundColor: Colors.white.withValues(alpha: 0.28), disabledForegroundColor: Colors.white.withValues(alpha: 0.52), shape: const CircleBorder(), padding: EdgeInsets.zero, elevation: 0, ), child: Icon( widget.isRecording ? Icons.pause_rounded : Icons.mic_rounded, size: 48, ), ), ), ), ); } } class _VoiceInformationField extends StatelessWidget { const _VoiceInformationField({ required this.samples, required this.active, required this.progress, required this.liveLevel, }); final List samples; final bool active; final double progress; final double liveLevel; @override Widget build(BuildContext context) { return Stack( alignment: Alignment.center, children: [ Container( width: 220, height: 220, decoration: BoxDecoration( shape: BoxShape.circle, color: const Color(0xFF0C0718), boxShadow: [ BoxShadow( color: const Color( 0xFFFF2D75, ).withValues(alpha: active ? 0.18 : 0.08), blurRadius: 90, spreadRadius: 18, ), BoxShadow( color: const Color( 0xFF38F5D3, ).withValues(alpha: active ? 0.12 : 0.05), blurRadius: 120, spreadRadius: 14, ), ], ), ), LayoutBuilder( builder: (context, constraints) { return CustomPaint( size: Size( constraints.maxWidth, constraints.maxHeight.clamp(280.0, 520.0), ), painter: _VoiceInformationPainter( samples: samples, active: active, progress: progress, liveLevel: liveLevel, ), ); }, ), ], ); } } class _VoiceInformationPainter extends CustomPainter { const _VoiceInformationPainter({ required this.samples, required this.active, required this.progress, required this.liveLevel, }); final List samples; final bool active; final double progress; final double liveLevel; @override void paint(Canvas canvas, Size size) { const columns = 18; const rows = 12; const gap = 6.0; final cellSize = math .min( (size.width - gap * (columns - 1)) / columns, (size.height - gap * (rows - 1)) / rows, ) .clamp(8.0, 22.0); final cellWidth = cellSize; final cellHeight = cellSize; final gridWidth = columns * cellWidth + (columns - 1) * gap; final gridHeight = rows * cellHeight + (rows - 1) * gap; final startX = (size.width - gridWidth) / 2; final startY = (size.height - gridHeight) / 2; final activeCells = (progress * columns * rows).round(); final backgroundPaint = Paint() ..color = Colors.white.withValues(alpha: 0.105) ..style = PaintingStyle.fill; final borderPaint = Paint() ..color = Colors.white.withValues(alpha: 0.045) ..style = PaintingStyle.stroke ..strokeWidth = 1; final glowPaint = Paint() ..color = const Color(0xFFE11D48).withValues(alpha: active ? 0.18 : 0.08) ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 18); for (var row = 0; row < rows; row++) { for (var column = 0; column < columns; column++) { final cellIndex = row * columns + column; final sampleIndex = samples.isEmpty ? 0 : ((cellIndex / (columns * rows - 1)) * (samples.length - 1)) .round(); final signal = samples.isEmpty ? 0.0 : samples[sampleIndex].clamp(0.0, 1.0); final fillOrder = _cellOrder(cellIndex, columns * rows); final filled = fillOrder < activeCells; final x = startX + column * (cellWidth + gap); final y = startY + row * (cellHeight + gap); final rect = RRect.fromRectAndRadius( Rect.fromLTWH(x, y, cellWidth, cellHeight), const Radius.circular(4), ); canvas.drawRRect(rect, backgroundPaint); canvas.drawRRect(rect, borderPaint); final centerDistance = (row - (rows - 1) / 2).abs(); final columnSignal = (signal * 0.78 + liveLevel * 0.22).clamp(0.0, 1.0); final waveReach = 0.45 + columnSignal * rows * 0.44; final hashLift = _hashUnit(cellIndex) * 0.55; final inWave = centerDistance <= waveReach + hashLift; if (inWave) { final waveAlpha = active ? 0.10 + columnSignal * 0.18 : 0.08 + columnSignal * 0.06; final wavePaint = Paint() ..color = Colors.white.withValues(alpha: waveAlpha) ..style = PaintingStyle.fill; canvas.drawRRect(rect, wavePaint); } if (filled) { final paint = Paint() ..shader = LinearGradient( colors: [ const Color( 0xFFE11D48, ).withValues(alpha: 0.78 + columnSignal * 0.18), const Color( 0xFFFF6B8A, ).withValues(alpha: 0.58 + columnSignal * 0.18), ], ).createShader(rect.outerRect) ..style = PaintingStyle.fill; canvas.drawRRect(rect.inflate(1.2 + columnSignal * 1.8), glowPaint); canvas.drawRRect(rect, paint); } } } } int _cellOrder(int index, int total) { return (((index + 11) * 73) % total).toInt(); } double _hashUnit(int index) { final value = math.sin(index * 12.9898 + 78.233) * 43758.5453; return value - value.floorToDouble(); } @override bool shouldRepaint(covariant _VoiceInformationPainter oldDelegate) { return oldDelegate.samples != samples || oldDelegate.active != active || oldDelegate.progress != progress || oldDelegate.liveLevel != liveLevel; } } 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.dark, required this.onClose, }); final int step; final int total; final bool dark; 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 ? (dark ? const Color(0xFFFF2D75) : Theme.of(context).colorScheme.primary) : (dark ? Colors.white.withValues(alpha: 0.16) : const Color(0xFFE0D8CA)), borderRadius: BorderRadius.circular(99), ), ), ), if (index != total - 1) const SizedBox(width: 6), ], ], ), ), const SizedBox(width: 10), IconButton( onPressed: onClose, icon: Icon(Icons.close, color: dark ? Colors.white : null), tooltip: 'Закрыть', ), ], ); } }