import 'dart:async'; import 'dart:math' as math; 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:waveform_flutter/waveform_flutter.dart'; import 'package:waveform_recorder/waveform_recorder.dart'; import '../api/mapflow_api.dart'; import '../auth/telegram_login_button.dart'; import '../auth/telegram_session.dart' as telegram_session; 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 _waveController = WaveformRecorderController( interval: const Duration(milliseconds: 45), config: const RecordConfig( numChannels: 1, sampleRate: 44100, autoGain: true, echoCancel: true, noiseSuppress: true, ), ); Future>? _nearbyPlacesFuture; StreamSubscription? _amplitudeSub; var _step = 0; var _informationUnits = 0.0; var _recording = false; var _submitting = false; var _micAllowed = true; var _noiseDb = -72.0; var _voicePeakDb = -34.0; var _liveLevel = 0.0; DateTime? _lastInformationAt; @override void initState() { super.initState(); } @override void dispose() { _amplitudeSub?.cancel(); _waveController.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 { await _waveController.startRecording(); await _amplitudeSub?.cancel(); _lastInformationAt = DateTime.now(); _amplitudeSub = _waveController.amplitudeStream.listen(_handleAmplitude); setState(() { _micAllowed = true; _recording = true; _liveLevel = 0; _informationUnits = 0; }); } Future _stopRecording() async { await _amplitudeSub?.cancel(); _amplitudeSub = null; await _waveController.stopRecording(); _lastInformationAt = null; if (!mounted) { return; } setState(() { _recording = false; _liveLevel = 0; }); } void _handleAmplitude(Amplitude amplitude) { final currentDb = amplitude.current; final now = DateTime.now(); final level = _normalizeDbLevel(currentDb); final informationDelta = _consumeInformationDelta(level, now); setState(() { _liveLevel = _smoothLevel(_liveLevel, level); _informationUnits = math.min( _minimumInformationUnits, _informationUnits + informationDelta, ); }); ref .read(placeControllerProvider.notifier) .setReviewDuration(_waveController.timeElapsed); } 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; } @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, waveController: _waveController, 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.waveController, 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 WaveformRecorderController waveController; 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: _LibraryWaveSurface( controller: waveController, 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 _LibraryWaveSurface extends StatelessWidget { const _LibraryWaveSurface({ required this.controller, required this.active, required this.progress, required this.liveLevel, }); final WaveformRecorderController controller; final bool active; final double progress; final double liveLevel; @override Widget build(BuildContext context) { return Stack( alignment: Alignment.center, children: [ DecoratedBox( decoration: BoxDecoration( shape: BoxShape.circle, boxShadow: [ BoxShadow( color: const Color( 0xFFFF2D75, ).withValues(alpha: active ? 0.20 + liveLevel * 0.18 : 0.06), blurRadius: 110, spreadRadius: 24, ), BoxShadow( color: const Color( 0xFF38F5D3, ).withValues(alpha: active ? 0.10 + liveLevel * 0.12 : 0.04), blurRadius: 130, spreadRadius: 14, ), ], ), child: const SizedBox.square(dimension: 210), ), Positioned.fill( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4), child: active ? AnimatedWaveList( key: ValueKey(controller.startTime), stream: controller.amplitudeStream, barBuilder: (animation, amplitude) => _VoiceWaveBar( animation: animation, amplitude: amplitude, progress: progress, ), ) : _IdleWaveBars(progress: progress), ), ), ], ); } } class _VoiceWaveBar extends StatelessWidget { const _VoiceWaveBar({ required this.animation, required this.amplitude, required this.progress, }); final Animation animation; final Amplitude amplitude; final double progress; @override Widget build(BuildContext context) { final level = _amplitudeLevel(amplitude.current); final height = 14 + level * 210; final color = Color.lerp( Colors.white.withValues(alpha: 0.28), const Color(0xFFFF2D75), progress.clamp(0.0, 1.0), )!; return SizeTransition( sizeFactor: animation, axis: Axis.horizontal, child: Align( alignment: Alignment.center, child: Container( width: 5, height: height, margin: const EdgeInsets.symmetric(horizontal: 2.5), decoration: BoxDecoration( borderRadius: BorderRadius.circular(20), gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [ color.withValues(alpha: 0.42), color, const Color(0xFFFF7A90), ], ), boxShadow: [ BoxShadow( color: color.withValues(alpha: 0.38), blurRadius: 18, spreadRadius: 1, ), ], ), ), ), ); } double _amplitudeLevel(double db) { final normalized = ((db.clamp(-76.0, -6.0) + 76.0) / 70.0).clamp(0.0, 1.0); return math.pow(normalized, 0.62).toDouble(); } } class _IdleWaveBars extends StatelessWidget { const _IdleWaveBars({required this.progress}); final double progress; @override Widget build(BuildContext context) { final color = Color.lerp( Colors.white.withValues(alpha: 0.18), const Color(0xFFFF2D75), progress.clamp(0.0, 1.0), )!; return Center( child: SizedBox( height: 220, child: Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: List.generate(26, (index) { final distance = (index - 12.5).abs(); final height = 18 + math.max(0.0, 1 - distance / 13) * 56; return Container( width: 4, height: height, margin: const EdgeInsets.symmetric(horizontal: 3), decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(20), ), ); }), ), ), ); } } 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: 'Закрыть', ), ], ); } }