import 'dart:async'; import 'dart:convert'; 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:flutter_svg/flutter_svg.dart'; import 'package:latlong2/latlong.dart' hide Path; import 'package:waveform_flutter/waveform_flutter.dart' show Amplitude; 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 (state.currentUser?.isAdmin == true) SafeArea( child: Align( alignment: Alignment.topRight, child: _AdminReviewsButton( onPressed: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => const AdminVoiceExperiencesScreen(), ), ), ), ), ), 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) : _AvatarImage(url: imageUrl, fallback: 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'; } } class _AdminReviewsButton extends StatelessWidget { const _AdminReviewsButton({required this.onPressed}); final VoidCallback onPressed; @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(top: 8, right: 12), child: FilledButton.icon( onPressed: onPressed, icon: const Icon(Icons.table_rows_outlined, size: 18), label: const Text('Отзывы'), style: FilledButton.styleFrom( backgroundColor: const Color(0xFFFFFBF5), foregroundColor: const Color(0xFF17211D), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), ), ), ); } } enum _AvatarAction { logout } class _AvatarImage extends StatelessWidget { const _AvatarImage({required this.url, required this.fallback}); final String url; final String fallback; @override Widget build(BuildContext context) { if (url.endsWith('.svg') || url.contains('.svg?')) { return SvgPicture.network( url, fit: BoxFit.cover, placeholderBuilder: (_) => _AvatarFallback(text: fallback), errorBuilder: (_, _, _) => _AvatarFallback(text: fallback), ); } return Image.network( url, fit: BoxFit.cover, errorBuilder: (_, _, _) => _AvatarFallback(text: fallback), ); } } 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(); var _loading = false; var _message = ''; @override void initState() { super.initState(); final urlToken = telegram_session.telegramLoginTokenFromUrl(); if (urlToken.isNotEmpty) { _completeLogin(urlToken); } } Future _startLogin() async { setState(() { _loading = true; _message = ''; }); final login = await _api.startTelegramBotLogin(); telegram_session.openExternalUrl(login.botUrl); setState(() { _loading = false; _message = ''; }); } Future _completeLogin(String token) async { setState(() { _loading = true; _message = ''; }); final session = await _api.completeTelegramBotLogin(token); telegram_session.saveMapflowSessionToken(session.sessionToken); widget.onAuthenticated(); telegram_session.reloadApp(); } @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 (_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 = 16.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, 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); final file = _waveController.file; if (file == null) { throw StateError('Voice recording file is required.'); } final bytes = await file.readAsBytes(); await controller.publishReview( place: place, audioObjectKey: 'web-recording-${DateTime.now().microsecondsSinceEpoch}-${file.name}', audioContentBase64: base64Encode(bytes), audioMimeType: file.mimeType ?? 'audio/wav', ); if (!context.mounted) { return; } Navigator.of(context).pop(); }, ), }; return Scaffold( backgroundColor: const Color(0xFF05030B), body: SafeArea( child: Padding( padding: EdgeInsets.fromLTRB( 16, 10, 16, MediaQuery.viewInsetsOf(context).bottom + 18, ), child: Column( children: [ _StoryProgress( step: _step, total: 3, dark: true, 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 AdminVoiceExperiencesScreen extends StatefulWidget { const AdminVoiceExperiencesScreen({super.key}); @override State createState() => _AdminVoiceExperiencesScreenState(); } class _AdminVoiceExperiencesScreenState extends State { late final Future> _future = MapflowApi() .fetchVoiceExperiences(); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFFFFBF5), appBar: AppBar(title: const Text('Отзывы')), body: FutureBuilder>( future: _future, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const Center(child: CircularProgressIndicator()); } if (snapshot.hasError) { return Center(child: Text(snapshot.error.toString())); } final reviews = snapshot.data ?? const []; return ListView.separated( padding: const EdgeInsets.all(12), itemCount: reviews.length, separatorBuilder: (_, _) => const SizedBox(height: 8), itemBuilder: (context, index) { return _AdminVoiceExperienceRow(review: reviews[index]); }, ); }, ), ); } } class _AdminVoiceExperienceRow extends StatelessWidget { const _AdminVoiceExperienceRow({required this.review}); final VoiceExperienceDebug review; @override Widget build(BuildContext context) { final selectedTags = _selectedAdminTags(review.analysis); return Material( color: Colors.white, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.all(12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: Text( review.placeName, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.w900), ), ), Text( review.status, style: const TextStyle(fontWeight: FontWeight.w700), ), ], ), const SizedBox(height: 6), Text( '${review.userName} · ${review.durationSeconds}s', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: Color(0xFF6B6258)), ), if (review.transcript?.trim().isNotEmpty == true) ...[ const SizedBox(height: 8), Text( review.transcript!, maxLines: 3, overflow: TextOverflow.ellipsis, ), ], const SizedBox(height: 10), _AdminOntologySnowflake(selectedTags: selectedTags), ], ), ), ); } } class _AdminOntologySnowflake extends StatelessWidget { const _AdminOntologySnowflake({required this.selectedTags}); final Set selectedTags; static const _axes = [ _AdminOntologyAxis( id: 'energy', label: 'энергия', angle: -math.pi / 2, leaves: [ _AdminOntologyLeaf('calm', 'спокойное', -0.22), _AdminOntologyLeaf('dynamic', 'живое', 0.22), ], ), _AdminOntologyAxis( id: 'privacy', label: 'приватность', angle: -math.pi / 2 + math.pi * 2 / 5, leaves: [ _AdminOntologyLeaf('intimate', 'камерное', -0.2), _AdminOntologyLeaf('open', 'открытое', 0.2), ], ), _AdminOntologyAxis( id: 'function', label: 'сценарий', angle: -math.pi / 2 + math.pi * 4 / 5, leaves: [ _AdminOntologyLeaf('reset', 'выдохнуть', -0.25), _AdminOntologyLeaf('impress', 'впечатлить', 0), _AdminOntologyLeaf('transit', 'транзитное', 0.25), ], ), _AdminOntologyAxis( id: 'aesthetic', label: 'образ', angle: -math.pi / 2 + math.pi * 6 / 5, leaves: [ _AdminOntologyLeaf('clean', 'чистое', -0.2), _AdminOntologyLeaf('expressive', 'выразительное', 0.2), ], ), _AdminOntologyAxis( id: 'sociality', label: 'социальность', angle: -math.pi / 2 + math.pi * 8 / 5, leaves: [ _AdminOntologyLeaf('solo', 'для себя', -0.2), _AdminOntologyLeaf('group', 'для компании', 0.2), ], ), ]; @override Widget build(BuildContext context) { return SizedBox( height: 300, width: double.infinity, child: CustomPaint( painter: _AdminOntologySnowflakePainter( axes: _axes, selectedTags: selectedTags, ), ), ); } } class _AdminOntologySnowflakePainter extends CustomPainter { const _AdminOntologySnowflakePainter({ required this.axes, required this.selectedTags, }); final List<_AdminOntologyAxis> axes; final Set selectedTags; @override void paint(Canvas canvas, Size size) { final center = Offset(size.width / 2, size.height / 2); final radius = math.min(size.width, size.height); final axisRadius = radius * 0.25; final leafRadius = radius * 0.43; final baseLine = Paint() ..color = const Color(0xFFE6DDD2) ..strokeWidth = 1.3 ..style = PaintingStyle.stroke; final selectedLine = Paint() ..color = const Color(0xFFE11D48) ..strokeWidth = 2.2 ..strokeCap = StrokeCap.round ..style = PaintingStyle.stroke; final node = Paint()..color = const Color(0xFFDED3C7); final selectedNode = Paint()..color = const Color(0xFFE11D48); final centerNode = Paint()..color = const Color(0xFF241B18); canvas.drawCircle(center, 5, centerNode); _drawLabel(canvas, size, center + const Offset(0, 14), 'место', true); for (final axis in axes) { final axisOffset = Offset(math.cos(axis.angle), math.sin(axis.angle)); final axisPoint = center + axisOffset * axisRadius; final hasSelectedLeaf = axis.leaves.any( (leaf) => selectedTags.contains('${axis.id}:${leaf.id}'), ); canvas.drawLine( center, axisPoint, hasSelectedLeaf ? selectedLine : baseLine, ); canvas.drawCircle( axisPoint, hasSelectedLeaf ? 5.5 : 4.5, hasSelectedLeaf ? selectedNode : node, ); _drawLabel( canvas, size, axisPoint + axisOffset * 18, axis.label, hasSelectedLeaf, fontSize: 11, ); for (final leaf in axis.leaves) { final leafAngle = axis.angle + leaf.angleOffset; final leafOffset = Offset(math.cos(leafAngle), math.sin(leafAngle)); final leafPoint = center + leafOffset * leafRadius; final tag = '${axis.id}:${leaf.id}'; final selected = selectedTags.contains(tag); canvas.drawLine( axisPoint, leafPoint, selected ? selectedLine : baseLine, ); canvas.drawCircle( leafPoint, selected ? 8 : 5.5, selected ? selectedNode : node, ); _drawLabel( canvas, size, leafPoint + leafOffset * 20, leaf.label, selected, ); } } } void _drawLabel( Canvas canvas, Size size, Offset anchor, String label, bool selected, { double fontSize = 12, }) { final painter = TextPainter( text: TextSpan( text: label, style: TextStyle( color: selected ? const Color(0xFFE11D48) : const Color(0xFF746A60), fontSize: fontSize, fontWeight: selected ? FontWeight.w900 : FontWeight.w700, height: 1, ), ), textDirection: TextDirection.ltr, maxLines: 1, )..layout(maxWidth: 86); final dx = (anchor.dx - painter.width / 2).clamp( 0.0, size.width - painter.width, ); final dy = (anchor.dy - painter.height / 2).clamp( 0.0, size.height - painter.height, ); painter.paint(canvas, Offset(dx, dy)); } @override bool shouldRepaint(covariant _AdminOntologySnowflakePainter oldDelegate) { return oldDelegate.selectedTags != selectedTags; } } class _AdminOntologyAxis { const _AdminOntologyAxis({ required this.id, required this.label, required this.angle, required this.leaves, }); final String id; final String label; final double angle; final List<_AdminOntologyLeaf> leaves; } class _AdminOntologyLeaf { const _AdminOntologyLeaf(this.id, this.label, this.angleOffset); final String id; final String label; final double angleOffset; } Set _selectedAdminTags(Map? analysis) { final tags = analysis?['tags']; if (tags is! List) { return const {}; } return tags.whereType().toSet(); } 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( color: Colors.white, 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( color: Colors.white, 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(color: Color(0xFFFF2D75)), ); } if (snapshot.hasError) { return const Center( child: Icon( Icons.error_outline, color: Colors.white, size: 42, ), ); } final places = snapshot.data ?? const []; if (places.isEmpty) { return Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ const Icon( Icons.location_off_outlined, color: Colors.white, size: 42, ), const SizedBox(height: 10), Text( 'Нет мест в $radiusMetersм', style: const TextStyle(color: Colors.white), ), ], ), ); } return ListView.separated( itemCount: places.length, separatorBuilder: (_, _) => const SizedBox(height: 10), itemBuilder: (context, index) { final place = places[index]; return _NearbyPlaceCard( place: place, disabled: isSubmitting, onTap: () => onSelect(place), ); }, ); }, ), ), ], ), ); } } class _NearbyPlaceCard extends StatelessWidget { const _NearbyPlaceCard({ required this.place, required this.disabled, required this.onTap, }); final PlaceRecommendation place; final bool disabled; final VoidCallback onTap; @override Widget build(BuildContext context) { final primaryType = _formatType(place.googlePrimaryType); return Material( color: const Color(0xFF15111D), borderRadius: BorderRadius.circular(8), child: InkWell( onTap: disabled ? null : onTap, borderRadius: BorderRadius.circular(8), child: Padding( padding: const EdgeInsets.all(14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon(Icons.place_outlined, color: Colors.white70), const SizedBox(width: 10), Expanded( child: Text( place.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( color: Colors.white, fontWeight: FontWeight.w900, height: 1.1, ), ), ), ], ), if (primaryType != null) ...[ const SizedBox(height: 12), Wrap( spacing: 6, runSpacing: 6, children: [_PlaceTypeChip(label: primaryType, primary: true)], ), ], ], ), ), ), ); } String? _formatType(String? type) { if (type == null || type.isEmpty) { return null; } return type.replaceAll('_', ' '); } } class _PlaceTypeChip extends StatelessWidget { const _PlaceTypeChip({required this.label, required this.primary}); final String label; final bool primary; @override Widget build(BuildContext context) { return DecoratedBox( decoration: BoxDecoration( color: primary ? const Color(0xFFFF2D75) : Colors.white.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(8), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5), child: Text( label, style: TextStyle( color: primary ? Colors.white : const Color(0xFFECE7F0), fontSize: 12, fontWeight: FontWeight.w700, height: 1, ), ), ), ); } } 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.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 double liveLevel; final bool canContinue; final Future Function() onToggleRecording; final VoidCallback onNext; @override Widget build(BuildContext context) { final canFinish = canContinue; 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: _VoiceProgressGrid(progress: informationProgress)), if (!micAllowed) const Padding( padding: EdgeInsets.only(bottom: 10), child: Icon( Icons.mic_off_outlined, color: Color(0xFFFF7A90), size: 22, ), ), _VoiceRecordButton( progress: informationProgress, liveLevel: liveLevel, isRecording: isRecording, canFinish: canFinish, enabled: hasTelegramAuth && !isSubmitting, onPressed: canFinish ? onNext : onToggleRecording, ), ], ); } } class _VoiceProgressGrid extends StatelessWidget { const _VoiceProgressGrid({required this.progress}); final double progress; @override Widget build(BuildContext context) { const columns = 18; const rows = 12; const total = columns * rows; final filled = (progress.clamp(0.0, 1.0) * total).round(); return LayoutBuilder( builder: (context, constraints) { const gap = 4.0; final maxCellWidth = (constraints.maxWidth - gap * (columns - 1)) / columns; final maxCellHeight = (constraints.maxHeight - gap * (rows - 1)) / rows; final cellSize = math.max( 10.0, math.min(maxCellWidth, maxCellHeight).clamp(10.0, 28.0), ); final gridWidth = columns * cellSize + gap * (columns - 1); final gridHeight = rows * cellSize + gap * (rows - 1); return Center( child: SizedBox( width: gridWidth, height: gridHeight, child: Wrap( spacing: gap, runSpacing: gap, children: [ for (var index = 0; index < total; index++) _VoiceProgressCell( filled: _gridOrder(index, columns, rows) < filled, size: cellSize, ), ], ), ), ); }, ); } static int _gridOrder(int index, int columns, int rows) { final row = index ~/ columns; final column = index % columns; final centerX = (columns - 1) / 2; final centerY = (rows - 1) / 2; final dx = column - centerX; final dy = row - centerY; final radius = math.sqrt(dx * dx + dy * dy); final angle = math.atan2(dy, dx); final shell = (radius * 7.0 + angle * 5.0).floor(); final noise = (math.sin((column + 1) * 37.17 + (row + 1) * 91.43) * 10000) .abs(); return ((shell * 31 + noise.floor()) % (columns * rows)); } } class _VoiceProgressCell extends StatelessWidget { const _VoiceProgressCell({required this.filled, required this.size}); final bool filled; final double size; @override Widget build(BuildContext context) { return AnimatedContainer( duration: const Duration(milliseconds: 180), curve: Curves.easeOutCubic, width: size, height: size, decoration: BoxDecoration( color: filled ? const Color(0xFFFF2D75) : Colors.white.withValues(alpha: 0.11), borderRadius: BorderRadius.circular(3), boxShadow: filled ? [ BoxShadow( color: const Color(0xFFFF2D75).withValues(alpha: 0.34), blurRadius: 12, spreadRadius: 1, ), ] : null, ), ); } } class _VoiceRecordButton extends StatefulWidget { const _VoiceRecordButton({ required this.progress, required this.liveLevel, required this.isRecording, required this.canFinish, required this.enabled, required this.onPressed, }); final double progress; final double liveLevel; final bool isRecording; final bool canFinish; final bool enabled; final VoidCallback 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: 280, height: 280, 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.20 + level * 0.44) + level * 0.16, child: Container( width: 190, height: 190, 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: 216, height: 216, child: CircularProgressIndicator( value: widget.progress, strokeWidth: 8, strokeCap: StrokeCap.round, color: const Color(0xFFFF2D75), backgroundColor: Colors.white.withValues(alpha: 0.12), ), ), child!, ], ); }, child: SizedBox( width: 156, height: 156, 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.canFinish ? Icons.check_rounded : widget.isRecording ? Icons.pause_rounded : Icons.mic_rounded, size: 56, ), ), ), ), ); } } 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: 'Закрыть', ), ], ); } }