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 '../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 ?? _fallbackCenter, 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 _minimumVoiceSeconds = 30; final _recorder = AudioRecorder(); final _waveSamples = List.filled(64, 0.04); Timer? _timer; StreamSubscription? _audioStreamSub; var _step = 0; var _seconds = 0; var _recording = false; var _submitting = false; var _micAllowed = true; PlaceRecommendation? _selectedPlace; @override void initState() { super.initState(); } @override void dispose() { _timer?.cancel(); _audioStreamSub?.cancel(); _recorder.dispose(); super.dispose(); } Future _toggleRecording() async { if (_recording) { await _stopRecording(); return; } await _startRecording(); } Future _startRecording() async { 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); _timer = Timer.periodic(const Duration(seconds: 1), (_) { if (!mounted) { return; } setState(() => _seconds += 1); ref .read(placeControllerProvider.notifier) .setReviewDuration(Duration(seconds: _seconds)); }); setState(() { _micAllowed = true; _recording = true; }); } Future _stopRecording() async { _timer?.cancel(); await _audioStreamSub?.cancel(); _audioStreamSub = null; await _recorder.stop(); if (!mounted) { return; } setState(() => _recording = false); } void _handleAudioChunk(Uint8List chunk) { if (chunk.length < 2) { return; } final bytes = ByteData.sublistView(chunk); var sum = 0.0; var count = 0; for (var index = 0; index + 1 < chunk.length; index += 2) { final sample = bytes.getInt16(index, Endian.little) / 32768.0; sum += sample * sample; count += 1; } final rms = count == 0 ? 0.0 : math.sqrt(sum / count); final level = (rms * 7.5).clamp(0.03, 1.0).toDouble(); if (!mounted) { return; } setState(() { _waveSamples ..removeAt(0) ..add(level); }); } 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 content = switch (_step) { 0 => _IntroStep(onNext: () => setState(() => _step = 1)), 1 => _PlaceStep( places: places, onSelect: (place) { setState(() { _selectedPlace = place; _step = 2; }); controller.setReviewPlace(place.name); }, ), _ => _VoiceStep( placeName: _selectedPlace?.name ?? '', hasTelegramAuth: widget.hasTelegramAuth, seconds: _seconds, minimumSeconds: _minimumVoiceSeconds, time: _time, isRecording: _recording, isSubmitting: _submitting, micAllowed: _micAllowed, samples: _waveSamples, canContinue: widget.hasTelegramAuth && _seconds >= _minimumVoiceSeconds, onToggleRecording: _toggleRecording, onNext: () async { final selectedPlace = _selectedPlace; if (selectedPlace == null) { return; } if (_recording) { await _stopRecording(); } setState(() => _submitting = true); controller.setReviewPlace(selectedPlace.name); await controller.publishReview(coordinate: selectedPlace.coordinate); if (!context.mounted) { return; } 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( 'Поделись ощущением от места голосом. Мы разберем запись через 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.places, required this.onSelect}); final List places; final ValueChanged 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: 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), ), ); }, ), ), ], ), ); } } class _VoiceStep extends StatelessWidget { const _VoiceStep({ required this.placeName, required this.hasTelegramAuth, required this.seconds, required this.minimumSeconds, required this.time, required this.isRecording, required this.isSubmitting, required this.micAllowed, required this.samples, required this.canContinue, required this.onToggleRecording, required this.onNext, }); final String placeName; final bool hasTelegramAuth; final int seconds; final int minimumSeconds; final String time; final bool isRecording; final bool isSubmitting; final bool micAllowed; final List samples; final bool canContinue; final Future Function() onToggleRecording; final VoidCallback onNext; @override Widget build(BuildContext context) { return _StepLayout( body: Column( children: [ const Spacer(), Text( placeName.trim().isEmpty ? 'Место' : placeName.trim(), textAlign: TextAlign.center, style: Theme.of( context, ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900), ), const SizedBox(height: 8), Text(hasTelegramAuth ? 'Минимум $minimumSeconds секунд' : ''), const SizedBox(height: 26), _VoiceWave(samples: samples, active: isRecording), const SizedBox(height: 24), SizedBox( width: 132, height: 132, child: FilledButton( onPressed: isSubmitting || !hasTelegramAuth ? null : () => 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 / minimumSeconds).clamp(0.0, 1.0), ), if (!micAllowed) ...[ const SizedBox(height: 12), const Icon(Icons.mic_off_outlined, size: 22), ], const Spacer(), ], ), action: FilledButton( onPressed: canContinue && !isSubmitting ? onNext : null, child: Text(isSubmitting ? 'Отправляем' : 'Далее'), ), ); } } class _VoiceWave extends StatelessWidget { const _VoiceWave({required this.samples, required this.active}); final List samples; final bool active; @override Widget build(BuildContext context) { return SizedBox( height: 112, width: double.infinity, child: CustomPaint( painter: _VoiceWavePainter( samples: samples, active: active, color: Theme.of(context).colorScheme.primary, ), ), ); } } class _VoiceWavePainter extends CustomPainter { const _VoiceWavePainter({ required this.samples, required this.active, required this.color, }); final List samples; final bool active; final Color color; @override void paint(Canvas canvas, Size size) { final centerY = size.height / 2; final top = []; final bottom = []; for (var index = 0; index < samples.length; index++) { final x = samples.length == 1 ? 0.0 : index / (samples.length - 1) * size.width; final envelope = math.sin(index / (samples.length - 1) * math.pi); final amplitude = samples[index] * envelope * size.height * 0.46; final yTop = centerY - amplitude; final yBottom = centerY + amplitude; top.add(Offset(x, yTop)); bottom.add(Offset(x, yBottom)); } final topPath = Path()..addPolygon(top, false); final bottomPath = Path()..addPolygon(bottom, false); final shape = Path() ..addPolygon([...top, ...bottom.reversed], true) ..close(); final glow = Paint() ..color = color.withValues(alpha: active ? 0.18 : 0.08) ..strokeWidth = 18 ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round ..maskFilter = const MaskFilter.blur(BlurStyle.normal, 10); final stroke = Paint() ..shader = LinearGradient( colors: [ color.withValues(alpha: active ? 0.35 : 0.16), color.withValues(alpha: active ? 0.95 : 0.42), const Color(0xFFE11D48).withValues(alpha: active ? 0.86 : 0.34), ], ).createShader(Offset.zero & size) ..strokeWidth = 5 ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.round; final fill = Paint() ..shader = LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ color.withValues(alpha: active ? 0.20 : 0.08), const Color(0xFFE11D48).withValues(alpha: active ? 0.14 : 0.05), ], ).createShader(Offset.zero & size) ..style = PaintingStyle.fill; canvas.drawPath(shape, fill); canvas.drawPath(topPath, glow); canvas.drawPath(bottomPath, glow); canvas.drawPath(topPath, stroke); canvas.drawPath(bottomPath, stroke); canvas.drawCircle( Offset(size.width / 2, centerY), active ? 3.4 : 2.2, Paint()..color = color.withValues(alpha: active ? 0.82 : 0.36), ); } @override bool shouldRepaint(covariant _VoiceWavePainter oldDelegate) { return true; } } 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: 'Закрыть', ), ], ); } }