Files
flutter/lib/features/mapflow/presentation/mapflow_shell.dart
T
Ruslan Bakiev bb49a13b65
Build and deploy Flutter Web / build (push) Successful in 6m5s
Stabilize location driven review flow
2026-06-24 14:19:35 +07:00

3550 lines
102 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import 'package:latlong2/latlong.dart' hide Path;
import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart' as mbx;
import 'package:waveform_flutter/waveform_flutter.dart' show Amplitude;
import 'package:waveform_recorder/waveform_recorder.dart';
import '../../../app/router/app_router.dart';
import '../../../app/theme/mapflow_theme.dart';
import '../../../shared/auth/telegram_login_button.dart';
import '../../../shared/auth/telegram_session.dart' as telegram_session;
import '../../../shared/runtime_config/runtime_config.dart' as runtime_config;
import '../application/place_cubit.dart';
import '../data/auth_repository.dart';
import '../data/places_repository.dart';
import '../domain/place_models.dart';
import 'widgets/place_photo_card.dart';
String get _mapboxStyle {
final style = runtime_config.mapboxStyle().trim();
if (style.isEmpty ||
style == 'mapbox/standard' ||
style == mbx.MapboxStyles.STANDARD) {
return mbx.MapboxStyles.STANDARD_SATELLITE;
}
return style;
}
class MapflowShell extends StatelessWidget {
const MapflowShell({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<PlaceCubit, PlaceViewState>(
builder: (context, viewState) {
return switch (viewState.status) {
PlaceLoadStatus.loading => const _MapLoading(),
PlaceLoadStatus.failure => _MapError(
message: viewState.errorMessage ?? '',
),
PlaceLoadStatus.ready =>
viewState.placeState!.hasTelegramAuth
? _MapContent(state: viewState.placeState!)
: _TelegramLoginScreen(
onAuthenticated: () => context.read<PlaceCubit>().load(),
),
};
},
);
}
}
class _MapContent extends StatefulWidget {
const _MapContent({required this.state});
final PlaceState state;
@override
State<_MapContent> createState() => _MapContentState();
}
class _MapContentState extends State<_MapContent> {
@override
Widget build(BuildContext context) {
final state = widget.state;
final selected = state.selectedPlace;
final userCoordinate = state.userCoordinate;
final targetCenter = _focusCenter(state);
final reviewCoordinate = userCoordinate ?? selected?.coordinate;
final placeCubit = context.read<PlaceCubit>();
final traitCounts = _countPlaceTraits(state.places);
final availableTraits = [
for (final trait in PlaceTrait.values)
if ((traitCounts[trait] ?? 0) > 0) trait,
];
final hasVoiceRecommendation =
state.voiceFilterTranscript.isNotEmpty ||
state.voiceFilterTags.isNotEmpty;
final hasRecommendationSurface =
hasVoiceRecommendation || state.selectedTrait != null;
final activeRecommendationLabel = hasVoiceRecommendation
? 'Голосовой поиск'
: state.selectedTrait?.label;
return Scaffold(
body: Stack(
children: [
_MapboxMapLayer(
focusCenter: targetCenter,
places: state.recommendations,
selectedPlaceId: selected?.id,
userCoordinate: userCoordinate,
onPlaceTap: (place) => _selectAndShowPlace(context, place),
),
SafeArea(
child: Align(
alignment: Alignment.topLeft,
child: _UserAvatar(
user: state.currentUser,
onAdminReviews: state.currentUser?.isAdmin == true
? () => context.push(MapflowRoutes.adminReviews)
: null,
onLogout: () {
telegram_session.clearMapflowSession();
telegram_session.reloadApp();
},
),
),
),
SafeArea(
child: Align(
alignment: Alignment.topRight,
child: _AddReviewAction(
onPressed: reviewCoordinate == null
? null
: () => _openAddFlow(context, reviewCoordinate),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: SafeArea(
top: false,
child: hasRecommendationSurface
? Padding(
padding: const EdgeInsets.only(bottom: 70),
child: _PlaceCarousel(
places: state.recommendations,
onSelect: (place) =>
_selectAndShowPlace(context, place),
onFavoriteToggle: placeCubit.toggleFavorite,
),
)
: const SizedBox.shrink(),
),
),
Align(
alignment: Alignment.bottomCenter,
child: SafeArea(
top: false,
child: _MapBottomControls(
activeRecommendationLabel: activeRecommendationLabel,
onClearRecommendation: activeRecommendationLabel == null
? null
: hasVoiceRecommendation
? () => unawaited(placeCubit.clearVoiceFilter())
: placeCubit.clearTrait,
onSearch: () => _openSearchSheet(
context,
traits: availableTraits,
selectedTrait: state.selectedTrait,
traitCounts: traitCounts,
onVoice: placeCubit.recommendPlacesByVoice,
),
),
),
),
],
),
);
}
LatLng? _focusCenter(PlaceState state) {
return state.userCoordinate ?? state.selectedPlace?.coordinate;
}
Future<void> _openSearchSheet(
BuildContext context, {
required List<PlaceTrait> traits,
required PlaceTrait? selectedTrait,
required Map<PlaceTrait, int> traitCounts,
required Future<void> Function({
required String audioContentBase64,
required String audioMimeType,
})
onVoice,
}) async {
await showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (sheetContext) {
return _SearchSheet(
traits: traits,
selectedTrait: selectedTrait,
traitCounts: traitCounts,
onVoice: onVoice,
onSelectTrait: (trait) {
final controller = context.read<PlaceCubit>();
if (trait == selectedTrait) {
controller.clearTrait();
} else {
controller.selectTrait(trait);
}
Navigator.of(sheetContext).pop();
},
);
},
);
}
void _openAddFlow(BuildContext context, LatLng? coordinate) {
context.push(MapflowRoutes.addExperienceLocation(coordinate: coordinate));
}
void _selectAndShowPlace(BuildContext context, PlaceRecommendation place) {
final placeCubit = context.read<PlaceCubit>()..selectPlace(place.id);
final placeDetails = placeCubit.loadPlaceDetails(place);
unawaited(
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
isDismissible: true,
enableDrag: true,
showDragHandle: true,
builder: (sheetContext) {
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.64,
minChildSize: 0.24,
maxChildSize: 0.9,
builder: (_, scrollController) {
return _PlaceDetailsSheet(
place: place,
details: placeDetails,
scrollController: scrollController,
onFavoriteToggle: (selectedPlace) =>
placeCubit.toggleFavorite(selectedPlace),
onPhotoTap: (photoUrls, index) =>
_openPhotoViewer(sheetContext, photoUrls, index),
);
},
);
},
),
);
}
void _openPhotoViewer(
BuildContext context,
List<String> photoUrls,
int initialIndex,
) {
if (photoUrls.isEmpty) {
return;
}
unawaited(
showDialog<void>(
context: context,
barrierColor: Colors.black,
builder: (_) {
return _PlacePhotoViewer(
photoUrls: photoUrls,
initialIndex: initialIndex,
);
},
),
);
}
}
Map<PlaceTrait, int> _countPlaceTraits(List<PlaceRecommendation> places) {
final counts = <PlaceTrait, int>{};
for (final place in places) {
for (final trait in place.traits) {
counts[trait] = (counts[trait] ?? 0) + 1;
}
}
return counts;
}
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: context.mapflowTokens.mapPanel, width: 3),
),
),
),
);
}
}
class _ProjectedMarker {
const _ProjectedMarker({required this.place, required this.offset});
final PlaceRecommendation place;
final Offset offset;
}
class _MapboxMapLayer extends StatefulWidget {
const _MapboxMapLayer({
required this.focusCenter,
required this.places,
required this.selectedPlaceId,
required this.userCoordinate,
required this.onPlaceTap,
});
final LatLng? focusCenter;
final List<PlaceRecommendation> places;
final String? selectedPlaceId;
final LatLng? userCoordinate;
final ValueChanged<PlaceRecommendation> onPlaceTap;
@override
State<_MapboxMapLayer> createState() => _MapboxMapLayerState();
}
class _MapboxMapLayerState extends State<_MapboxMapLayer> {
static const _fallbackCenter = LatLng(16.0544, 108.2022);
final _viewportController = mbx.ViewportController();
late final mbx.CameraViewportState _initialViewport;
mbx.MapboxMap? _mapboxMap;
var _mapLoaded = false;
var _didInitialFlyIn = false;
var _cameraAnimationSequence = 0;
var _projectionVersion = 0;
LatLng? _lastFocusCenter;
List<_ProjectedMarker> _projectedPlaces = const [];
Offset? _projectedUserCoordinate;
@override
void initState() {
super.initState();
final initialFocus = widget.focusCenter;
_initialViewport = _cameraViewport(
initialFocus ?? _fallbackCenter,
zoom: initialFocus == null ? 11.2 : 15.0,
bearing: initialFocus == null ? 0 : -12,
pitch: initialFocus == null ? 0 : 60,
);
if (initialFocus != null) {
_didInitialFlyIn = true;
_lastFocusCenter = initialFocus;
}
}
@override
void didUpdateWidget(covariant _MapboxMapLayer oldWidget) {
super.didUpdateWidget(oldWidget);
_updateCameraForFocus();
unawaited(_projectMarkers());
}
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
mbx.MapWidget(
styleUri: _mapboxStyleUri,
viewport: _initialViewport,
viewportController: _viewportController,
onMapCreated: _onMapCreated,
onStyleLoadedListener: (_) {
if (!kIsWeb) {
unawaited(_enableGlobeProjection());
}
},
onMapLoadedListener: (_) {
_mapLoaded = true;
_updateCameraForFocus();
unawaited(_projectMarkers());
},
onCameraChangeListener: (_) => unawaited(_projectMarkers()),
onMapIdleListener: (_) => unawaited(_projectMarkers()),
),
for (final projected in _projectedPlaces)
Positioned(
left: projected.offset.dx - 26,
top: projected.offset.dy - 26,
width: 52,
height: 52,
child: _PlaceMarker(
selected: projected.place.id == widget.selectedPlaceId,
onTap: () => widget.onPlaceTap(projected.place),
),
),
if (_projectedUserCoordinate != null)
Positioned(
left: _projectedUserCoordinate!.dx - 15,
top: _projectedUserCoordinate!.dy - 15,
width: 30,
height: 30,
child: const _UserLocationMarker(),
),
],
);
}
void _onMapCreated(mbx.MapboxMap mapboxMap) {
_mapboxMap = mapboxMap;
}
@override
void dispose() {
_viewportController.dispose();
super.dispose();
}
Future<void> _enableGlobeProjection() async {
final mapboxMap = _mapboxMap;
if (mapboxMap == null) {
return;
}
await mapboxMap.style.setProjection(
mbx.StyleProjection(name: mbx.StyleProjectionName.globe),
);
}
void _updateCameraForFocus() {
final mapboxMap = _mapboxMap;
final focusCenter = widget.focusCenter;
if (!_mapLoaded || mapboxMap == null || focusCenter == null) {
return;
}
if (focusCenter == _lastFocusCenter) {
return;
}
_lastFocusCenter = focusCenter;
final camera = _cameraViewport(
focusCenter,
zoom: 15.0,
bearing: -12,
pitch: 60,
);
if (_didInitialFlyIn) {
_cameraAnimationSequence++;
_viewportController.moveTo(
camera,
transition: const mbx.EasingViewportTransition(
duration: Duration(milliseconds: 650),
),
);
return;
}
_didInitialFlyIn = true;
final animationSequence = ++_cameraAnimationSequence;
_viewportController.moveTo(
_cameraViewport(
_targetOrbitCenter(focusCenter),
zoom: 0.72,
bearing: _targetOrbitBearing(focusCenter),
pitch: 0,
),
transition: const mbx.EasingViewportTransition(
duration: Duration(milliseconds: 850),
),
);
Timer(const Duration(milliseconds: 520), () {
if (!mounted ||
animationSequence != _cameraAnimationSequence ||
_mapboxMap != mapboxMap) {
return;
}
_viewportController.moveTo(
camera,
transition: const mbx.FlyViewportTransition(
duration: Duration(milliseconds: 2800),
),
);
});
}
mbx.CameraViewportState _cameraViewport(
LatLng center, {
required double zoom,
required double bearing,
required double pitch,
}) {
return mbx.CameraViewportState(
center: _mapboxPoint(center),
zoom: zoom,
bearing: bearing,
pitch: pitch,
);
}
LatLng _targetOrbitCenter(LatLng target) {
final latitude = (target.latitude * 0.18).clamp(-18.0, 18.0);
return LatLng(latitude, target.longitude);
}
double _targetOrbitBearing(LatLng target) {
return (-target.longitude * 0.34).clamp(-64.0, 64.0);
}
Future<void> _projectMarkers() async {
final mapboxMap = _mapboxMap;
if (!_mapLoaded || mapboxMap == null) {
return;
}
final version = ++_projectionVersion;
final coordinates = [
for (final place in widget.places) _mapboxPoint(place.coordinate),
if (widget.userCoordinate != null) _mapboxPoint(widget.userCoordinate!),
];
if (coordinates.isEmpty) {
if (!mounted) {
return;
}
setState(() {
_projectedPlaces = const [];
_projectedUserCoordinate = null;
});
return;
}
final pixels = await mapboxMap.pixelsForCoordinates(coordinates);
if (!mounted || version != _projectionVersion) {
return;
}
setState(() {
_projectedPlaces = [
for (var index = 0; index < widget.places.length; index++)
_ProjectedMarker(
place: widget.places[index],
offset: Offset(pixels[index].x, pixels[index].y),
),
];
_projectedUserCoordinate = widget.userCoordinate == null
? null
: Offset(pixels.last.x, pixels.last.y);
});
}
}
class _UserAvatar extends StatelessWidget {
const _UserAvatar({
required this.user,
required this.onAdminReviews,
required this.onLogout,
});
final AppUser? user;
final VoidCallback? onAdminReviews;
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.adminReviews:
onAdminReviews?.call();
case _AvatarAction.logout:
onLogout();
}
},
itemBuilder: (_) => [
if (onAdminReviews != null)
const PopupMenuItem<_AvatarAction>(
value: _AvatarAction.adminReviews,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.table_rows_outlined, size: 18),
SizedBox(width: 10),
Text('Отзывы'),
],
),
),
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 _AddReviewAction extends StatelessWidget {
const _AddReviewAction({required this.onPressed});
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
final colorScheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.only(top: 8, right: 12),
child: FilledButton.icon(
onPressed: onPressed,
icon: const Icon(Icons.rate_review_outlined, size: 18),
label: const Text('Отзыв'),
style: FilledButton.styleFrom(
backgroundColor: tokens.mapPanel,
foregroundColor: colorScheme.onSurface,
side: BorderSide(color: tokens.mapPanelBorder),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
),
),
);
}
}
class _MapBottomControls extends StatelessWidget {
const _MapBottomControls({
required this.activeRecommendationLabel,
required this.onClearRecommendation,
required this.onSearch,
});
final String? activeRecommendationLabel;
final VoidCallback? onClearRecommendation;
final VoidCallback onSearch;
@override
Widget build(BuildContext context) {
final label = activeRecommendationLabel;
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_SearchLauncherButton(onPressed: onSearch),
if (label != null && onClearRecommendation != null) ...[
const SizedBox(width: 8),
Flexible(
child: _ActiveRecommendationChip(
label: label,
onClear: onClearRecommendation!,
),
),
],
],
),
);
}
}
class _ActiveRecommendationChip extends StatelessWidget {
const _ActiveRecommendationChip({required this.label, required this.onClear});
final String label;
final VoidCallback onClear;
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
return Material(
color: tokens.mapPanel,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(tokens.panelRadius),
side: BorderSide(color: tokens.mapPanelBorder),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 4, 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.travel_explore_rounded, size: 18),
const SizedBox(width: 8),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
const SizedBox(width: 4),
IconButton(
onPressed: onClear,
icon: const Icon(Icons.close_rounded, size: 18),
tooltip: 'Сбросить',
style: IconButton.styleFrom(
fixedSize: const Size.square(32),
minimumSize: const Size.square(32),
padding: EdgeInsets.zero,
),
),
],
),
),
);
}
}
class _SearchLauncherButton extends StatelessWidget {
const _SearchLauncherButton({required this.onPressed});
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
final colorScheme = Theme.of(context).colorScheme;
return FloatingActionButton.extended(
heroTag: 'map-search',
onPressed: onPressed,
icon: const Icon(Icons.search_rounded),
label: const Text('Поиск'),
backgroundColor: tokens.mapPanel,
foregroundColor: colorScheme.onSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(tokens.panelRadius),
side: BorderSide(color: tokens.mapPanelBorder),
),
);
}
}
class _SearchSheet extends StatelessWidget {
const _SearchSheet({
required this.traits,
required this.selectedTrait,
required this.traitCounts,
required this.onVoice,
required this.onSelectTrait,
});
final List<PlaceTrait> traits;
final PlaceTrait? selectedTrait;
final Map<PlaceTrait, int> traitCounts;
final Future<void> Function({
required String audioContentBase64,
required String audioMimeType,
})
onVoice;
final ValueChanged<PlaceTrait> onSelectTrait;
@override
Widget build(BuildContext context) {
return SafeArea(
child: SizedBox(
height: 420,
child: DefaultTabController(
length: 2,
child: Column(
children: [
const TabBar(
tabs: [
Tab(icon: Icon(Icons.mic_none_rounded), text: 'Голосом'),
Tab(icon: Icon(Icons.tune_rounded), text: 'Фильтры'),
],
),
Expanded(
child: TabBarView(
children: [
_VoiceFilterRecorder(
onApply: (audio) async {
Navigator.of(context).pop();
await onVoice(
audioContentBase64: audio.audioContentBase64,
audioMimeType: audio.audioMimeType,
);
},
),
_TraitPickerSheet(
traits: traits,
selectedTrait: selectedTrait,
traitCounts: traitCounts,
onSelect: onSelectTrait,
),
],
),
),
],
),
),
),
);
}
}
class _VoiceFilterAudio {
const _VoiceFilterAudio({
required this.audioContentBase64,
required this.audioMimeType,
});
final String audioContentBase64;
final String audioMimeType;
}
class _VoiceFilterRecorder extends StatefulWidget {
const _VoiceFilterRecorder({required this.onApply});
@override
State<_VoiceFilterRecorder> createState() => _VoiceFilterRecorderState();
final Future<void> Function(_VoiceFilterAudio audio) onApply;
}
class _VoiceFilterRecorderState extends State<_VoiceFilterRecorder> {
final _waveController = WaveformRecorderController(
interval: const Duration(milliseconds: 60),
config: const RecordConfig(
numChannels: 1,
sampleRate: 44100,
autoGain: true,
echoCancel: true,
noiseSuppress: true,
),
);
var _recording = false;
var _submitting = false;
var _hasRecording = false;
@override
void dispose() {
_waveController.dispose();
super.dispose();
}
Future<void> _toggleRecording() async {
if (_recording) {
await _waveController.stopRecording();
setState(() {
_recording = false;
_hasRecording = true;
});
return;
}
await _waveController.startRecording();
setState(() => _recording = true);
}
Future<void> _apply() async {
if (_recording) {
await _waveController.stopRecording();
setState(() {
_recording = false;
_hasRecording = true;
});
}
final file = _waveController.file;
if (file == null) {
throw StateError('Voice filter recording file is required.');
}
setState(() => _submitting = true);
final bytes = await file.readAsBytes();
if (!mounted) {
return;
}
await widget.onApply(
_VoiceFilterAudio(
audioContentBase64: base64Encode(bytes),
audioMimeType: file.mimeType ?? 'audio/wav',
),
);
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(18, 24, 18, 18),
child: Column(
children: [
Expanded(
child: Center(
child: IconButton.filled(
onPressed: _submitting ? null : _toggleRecording,
icon: Icon(_recording ? Icons.stop_rounded : Icons.mic_rounded),
iconSize: 44,
style: IconButton.styleFrom(
fixedSize: const Size.square(112),
minimumSize: const Size.square(112),
),
tooltip: _recording ? 'Стоп' : 'Записать',
),
),
),
FilledButton(
onPressed: _submitting || !_hasRecording ? null : _apply,
child: _submitting
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Применить'),
),
],
),
);
}
}
enum _AvatarAction { adminReviews, 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) {
final tokens = context.mapflowTokens;
return ColoredBox(
color: tokens.mapPanel,
child: Center(
child: Text(
text,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface,
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> {
late final AuthRepository _authRepository;
var _loading = false;
var _message = '';
@override
void initState() {
super.initState();
_authRepository = context.read<AuthRepository>();
final urlToken = telegram_session.telegramLoginTokenFromUrl();
if (urlToken.isNotEmpty) {
_completeLogin(urlToken);
}
}
Future<void> _startLogin() async {
setState(() {
_loading = true;
_message = '';
});
final login = await _authRepository.startTelegramBotLogin();
telegram_session.openExternalUrl(login.botUrl);
setState(() {
_loading = false;
_message = '';
});
}
Future<void> _completeLogin(String token) async {
setState(() {
_loading = true;
_message = '';
});
final session = await _authRepository.completeTelegramBotLogin(token);
telegram_session.saveMapflowSessionToken(session.sessionToken);
widget.onAuthenticated();
telegram_session.reloadApp();
}
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
return Scaffold(
backgroundColor: tokens.darkSurface,
body: Stack(
children: [
const Positioned.fill(child: _AnimatedEarthBackdrop()),
SafeArea(
child: Center(
child: SizedBox(
width: 320,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'MapFlow',
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(
color: tokens.onDark,
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?.copyWith(color: tokens.onDark),
),
],
],
),
),
),
),
],
),
);
}
}
class _AnimatedEarthBackdrop extends StatefulWidget {
const _AnimatedEarthBackdrop();
@override
State<_AnimatedEarthBackdrop> createState() => _AnimatedEarthBackdropState();
}
class _AnimatedEarthBackdropState extends State<_AnimatedEarthBackdrop>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 18),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return CustomPaint(
painter: _EarthBackdropPainter(
progress: _controller.value,
tokens: context.mapflowTokens,
),
child: const SizedBox.expand(),
);
},
),
);
}
}
class _EarthBackdropPainter extends CustomPainter {
const _EarthBackdropPainter({required this.progress, required this.tokens});
final double progress;
final MapflowThemeTokens tokens;
static const _landMasses = <({double lon, double lat, double sx, double sy})>[
(lon: -106, lat: 42, sx: 0.30, sy: 0.18),
(lon: -82, lat: 18, sx: 0.20, sy: 0.14),
(lon: -62, lat: -14, sx: 0.22, sy: 0.25),
(lon: 16, lat: 8, sx: 0.23, sy: 0.31),
(lon: 46, lat: 48, sx: 0.34, sy: 0.18),
(lon: 78, lat: 25, sx: 0.32, sy: 0.20),
(lon: 116, lat: -23, sx: 0.19, sy: 0.13),
(lon: 137, lat: 36, sx: 0.10, sy: 0.07),
(lon: -42, lat: 73, sx: 0.16, sy: 0.08),
];
static const _routes =
<({double fromLon, double fromLat, double toLon, double toLat})>[
(fromLon: -74, fromLat: 41, toLon: 2, toLat: 49),
(fromLon: 2, fromLat: 49, toLon: 105, toLat: 16),
(fromLon: 139, fromLat: 35, toLon: 105, toLat: 16),
(fromLon: -122, fromLat: 37, toLon: 139, toLat: 35),
];
@override
void paint(Canvas canvas, Size size) {
_paintSpace(canvas, size);
_paintOrbits(canvas, size);
_paintEarth(canvas, size);
}
void _paintSpace(Canvas canvas, Size size) {
final rect = Offset.zero & size;
canvas.drawRect(
rect,
Paint()
..shader = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
tokens.darkSurface,
const Color(0xFF07151B),
const Color(0xFF150918),
tokens.darkSurface,
],
).createShader(rect),
);
final glowCenter = Offset(size.width * 0.77, size.height * 0.18);
canvas.drawCircle(
glowCenter,
math.max(size.width, size.height) * 0.48,
Paint()
..shader =
RadialGradient(
colors: [
const Color(0xFFFFB86B).withValues(alpha: 0.20),
tokens.voiceAccent.withValues(alpha: 0.08),
Colors.transparent,
],
stops: const [0, 0.34, 1],
).createShader(
Rect.fromCircle(
center: glowCenter,
radius: math.max(size.width, size.height) * 0.48,
),
),
);
final stars = Paint()..color = Colors.white;
for (var index = 0; index < 96; index++) {
final xSeed = math.sin(index * 12.9898) * 43758.5453;
final ySeed = math.sin(index * 78.233) * 24634.6345;
final x = (xSeed - xSeed.floor()) * size.width;
final y = (ySeed - ySeed.floor()) * size.height;
final twinkle = 0.35 + 0.65 * math.sin(progress * math.pi * 2 + index);
stars.color = Colors.white.withValues(alpha: 0.10 + twinkle * 0.28);
canvas.drawCircle(Offset(x, y), 0.7 + (index % 3) * 0.35, stars);
}
}
void _paintOrbits(Canvas canvas, Size size) {
final center = _earthCenter(size);
final radius = _earthRadius(size);
final orbitPaint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.1
..color = const Color(0xFF5BE7C4).withValues(alpha: 0.15);
for (final scale in const [1.28, 1.48, 1.72]) {
canvas.save();
canvas.translate(center.dx, center.dy);
canvas.rotate(-0.46 + progress * math.pi * 0.18);
canvas.scale(1, 0.34);
canvas.drawCircle(Offset.zero, radius * scale, orbitPaint);
canvas.restore();
}
final satelliteAngle = progress * math.pi * 2;
final satellite = Offset(
center.dx + math.cos(satelliteAngle) * radius * 1.72,
center.dy + math.sin(satelliteAngle) * radius * 0.58,
);
canvas.drawCircle(
satellite,
3.6,
Paint()..color = const Color(0xFFFFD38A).withValues(alpha: 0.92),
);
}
void _paintEarth(Canvas canvas, Size size) {
final center = _earthCenter(size);
final radius = _earthRadius(size);
final sphere = Rect.fromCircle(center: center, radius: radius);
canvas.drawCircle(
center.translate(radius * 0.10, radius * 0.12),
radius * 1.06,
Paint()
..shader = RadialGradient(
colors: [Colors.black.withValues(alpha: 0.46), Colors.transparent],
).createShader(sphere.inflate(radius * 0.18)),
);
canvas.drawCircle(
center,
radius * 1.02,
Paint()
..shader = RadialGradient(
center: const Alignment(-0.48, -0.62),
colors: [
const Color(0xFF8AF8FF).withValues(alpha: 0.32),
const Color(0xFF124968).withValues(alpha: 0.72),
Colors.transparent,
],
stops: const [0, 0.64, 1],
).createShader(sphere.inflate(radius * 0.18)),
);
canvas.save();
canvas.clipPath(Path()..addOval(sphere));
canvas.drawOval(
sphere,
Paint()
..shader = RadialGradient(
center: const Alignment(-0.42, -0.50),
radius: 1.08,
colors: const [
Color(0xFF7AF6FF),
Color(0xFF1589B0),
Color(0xFF073456),
Color(0xFF030A1A),
],
stops: [0, 0.34, 0.72, 1],
).createShader(sphere),
);
_paintGrid(canvas, center, radius);
_paintLand(canvas, center, radius);
_paintRoutes(canvas, center, radius);
_paintTerminator(canvas, center, radius, sphere);
canvas.restore();
canvas.drawOval(
sphere,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 1.5
..color = const Color(0xFFB7FFF5).withValues(alpha: 0.36),
);
}
void _paintGrid(Canvas canvas, Offset center, double radius) {
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = 0.8
..color = Colors.white.withValues(alpha: 0.11);
for (final lat in const [-60, -30, 0, 30, 60]) {
final y = center.dy - math.sin(_radians(lat.toDouble())) * radius * 0.72;
final width = math.cos(_radians(lat.toDouble())) * radius;
canvas.drawOval(
Rect.fromCenter(
center: Offset(center.dx, y),
width: width * 2,
height: width * 0.38,
),
paint,
);
}
for (var meridian = 0; meridian < 6; meridian++) {
canvas.save();
canvas.translate(center.dx, center.dy);
canvas.rotate((meridian / 6) * math.pi + progress * math.pi * 2);
canvas.scale(0.20, 1);
canvas.drawCircle(Offset.zero, radius, paint);
canvas.restore();
}
}
void _paintLand(Canvas canvas, Offset center, double radius) {
final landPaint = Paint()
..color = const Color(0xFF4EDC9B).withValues(alpha: 0.86);
final highlightPaint = Paint()
..color = const Color(0xFFFFD38A).withValues(alpha: 0.34);
for (final land in _landMasses) {
final projected = _project(land.lon, land.lat, center, radius);
if (projected == null) {
continue;
}
final (:point, :depth) = projected;
final alpha = (0.30 + depth * 0.70).clamp(0.0, 1.0);
landPaint.color = const Color(0xFF4EDC9B).withValues(alpha: alpha * 0.88);
highlightPaint.color = const Color(
0xFFFFD38A,
).withValues(alpha: alpha * 0.26);
final landRect = Rect.fromCenter(
center: point,
width: radius * land.sx * (0.62 + depth * 0.58),
height: radius * land.sy * (0.56 + depth * 0.36),
);
canvas.save();
canvas.translate(point.dx, point.dy);
canvas.rotate(math.sin(land.lon) * 0.65);
canvas.translate(-point.dx, -point.dy);
canvas.drawOval(landRect, landPaint);
canvas.drawOval(landRect.deflate(radius * 0.018), highlightPaint);
canvas.restore();
}
}
void _paintRoutes(Canvas canvas, Offset center, double radius) {
final paint = Paint()
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeWidth = 1.4
..color = tokens.voiceAccentSoft.withValues(alpha: 0.38);
final pulsePaint = Paint()
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeWidth = 2.8
..color = tokens.voiceAccent.withValues(alpha: 0.44);
for (var index = 0; index < _routes.length; index++) {
final route = _routes[index];
final from = _project(route.fromLon, route.fromLat, center, radius);
final to = _project(route.toLon, route.toLat, center, radius);
if (from == null || to == null) {
continue;
}
final path = Path()
..moveTo(from.point.dx, from.point.dy)
..quadraticBezierTo(
(from.point.dx + to.point.dx) / 2,
math.min(from.point.dy, to.point.dy) - radius * (0.18 + index * 0.03),
to.point.dx,
to.point.dy,
);
canvas.drawPath(path, paint);
final metric = path.computeMetrics().first;
final head = ((progress + index * 0.19) % 1) * metric.length;
final segment = metric.extractPath(
math.max(0, head - metric.length * 0.12),
head,
);
canvas.drawPath(segment, pulsePaint);
}
}
void _paintTerminator(
Canvas canvas,
Offset center,
double radius,
Rect sphere,
) {
canvas.drawOval(
sphere.shift(Offset(radius * 0.22, radius * 0.06)),
Paint()
..shader = LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [
Colors.transparent,
Colors.black.withValues(alpha: 0.18),
Colors.black.withValues(alpha: 0.68),
],
stops: const [0, 0.52, 1],
).createShader(sphere),
);
canvas.drawCircle(
center.translate(-radius * 0.28, -radius * 0.30),
radius * 0.22,
Paint()
..shader =
RadialGradient(
colors: [
Colors.white.withValues(alpha: 0.22),
Colors.transparent,
],
).createShader(
Rect.fromCircle(
center: center.translate(-radius * 0.28, -radius * 0.30),
radius: radius * 0.22,
),
),
);
}
({Offset point, double depth})? _project(
double lon,
double lat,
Offset center,
double radius,
) {
final longitude = _radians(lon + progress * 360);
final latitude = _radians(lat);
final x = math.cos(latitude) * math.sin(longitude);
final z = math.cos(latitude) * math.cos(longitude);
if (z < -0.18) {
return null;
}
final y = -math.sin(latitude) * 0.72;
final depthScale = 0.72 + z * 0.28;
return (
point: Offset(
center.dx + x * radius * depthScale,
center.dy + y * radius * depthScale,
),
depth: ((z + 0.18) / 1.18).clamp(0.0, 1.0),
);
}
Offset _earthCenter(Size size) {
return Offset(size.width * 0.50, size.height * 0.40);
}
double _earthRadius(Size size) {
final base = math.min(size.width, size.height);
final radius = base * 0.31;
return radius.clamp(116.0, 310.0);
}
double _radians(double degrees) => degrees * math.pi / 180;
@override
bool shouldRepaint(covariant _EarthBackdropPainter oldDelegate) {
return oldDelegate.progress != progress || oldDelegate.tokens != tokens;
}
}
class _MapLoading extends StatelessWidget {
const _MapLoading();
static const _iconPath = 'icons/mapflow-signal-spoon.svg';
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
return Scaffold(
backgroundColor: tokens.darkSurface,
body: Center(
child: SizedBox.square(
dimension: 112,
child: Stack(
alignment: Alignment.center,
children: [
SizedBox.square(
dimension: 96,
child: CircularProgressIndicator(
strokeWidth: 3,
color: tokens.voiceAccent,
backgroundColor: tokens.onDark.withValues(alpha: 0.1),
),
),
ClipOval(
child: SvgPicture.network(
_iconPath,
width: 72,
height: 72,
fit: BoxFit.cover,
),
),
],
),
),
),
);
}
}
class _MapError extends StatelessWidget {
const _MapError({required this.message});
final String message;
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
return Scaffold(
body: Stack(
children: [
const _MapboxMapLayer(
focusCenter: LatLng(16.0544, 108.2022),
places: [],
selectedPlaceId: null,
userCoordinate: null,
onPlaceTap: _ignorePlaceTap,
),
SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: Container(
margin: const EdgeInsets.all(12),
padding: const EdgeInsets.all(12),
color: tokens.mapPanel,
child: Text(
message,
maxLines: 3,
overflow: TextOverflow.ellipsis,
),
),
),
),
],
),
);
}
}
void _ignorePlaceTap(PlaceRecommendation _) {}
mbx.Point _mapboxPoint(LatLng coordinate) {
return mbx.Point(
coordinates: mbx.Position(coordinate.longitude, coordinate.latitude),
);
}
String get _mapboxStyleUri {
if (_mapboxStyle.startsWith('mapbox://')) {
return _mapboxStyle;
}
if (_mapboxStyle.startsWith('mapbox/')) {
return 'mapbox://styles/$_mapboxStyle';
}
return _mapboxStyle;
}
class _TraitPickerSheet extends StatelessWidget {
const _TraitPickerSheet({
required this.selectedTrait,
required this.traits,
required this.traitCounts,
required this.onSelect,
});
final PlaceTrait? selectedTrait;
final List<PlaceTrait> traits;
final Map<PlaceTrait, int> traitCounts;
final ValueChanged<PlaceTrait> onSelect;
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
return SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 18, 16, 18),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final item in traits)
ChoiceChip(
avatar: Icon(item.icon, size: 17),
label: Text('${item.label} ${traitCounts[item] ?? 0}'),
selected: item == selectedTrait,
onSelected: (_) => onSelect(item),
backgroundColor: tokens.darkSurfaceAlt,
selectedColor: Theme.of(context).colorScheme.primaryContainer,
side: BorderSide(color: tokens.mapPanelBorder),
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;
final tokens = context.mapflowTokens;
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
decoration: BoxDecoration(
color: selected ? color : tokens.mapPanel,
shape: BoxShape.circle,
border: Border.all(color: color, width: selected ? 3 : 2),
boxShadow: [
BoxShadow(
color: tokens.mapShadow,
blurRadius: 14,
offset: const Offset(0, 8),
),
],
),
child: Icon(Icons.place, color: selected ? tokens.onDark : color),
),
);
}
}
class _PlaceCarousel extends StatelessWidget {
const _PlaceCarousel({
required this.places,
required this.onSelect,
required this.onFavoriteToggle,
});
final List<PlaceRecommendation> places;
final ValueChanged<PlaceRecommendation> onSelect;
final ValueChanged<PlaceRecommendation> onFavoriteToggle;
@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),
onFavoriteToggle: () => onFavoriteToggle(place),
);
},
),
);
}
}
class _PlaceDetailsSheet extends StatefulWidget {
const _PlaceDetailsSheet({
required this.place,
required this.details,
required this.scrollController,
required this.onFavoriteToggle,
required this.onPhotoTap,
});
final PlaceRecommendation place;
final Future<PlaceRecommendation> details;
final ScrollController scrollController;
final ValueChanged<PlaceRecommendation> onFavoriteToggle;
final void Function(List<String> photoUrls, int index) onPhotoTap;
@override
State<_PlaceDetailsSheet> createState() => _PlaceDetailsSheetState();
}
class _PlaceDetailsSheetState extends State<_PlaceDetailsSheet> {
late var _isFavorite = widget.place.isFavorite;
var _hoursExpanded = false;
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
final colorScheme = Theme.of(context).colorScheme;
return FutureBuilder<PlaceRecommendation>(
future: widget.details,
builder: (context, snapshot) {
final place = snapshot.data ?? widget.place;
final loadingDetails =
snapshot.connectionState != ConnectionState.done &&
place.photoUrls.isEmpty;
final openingSummary = _openingSummary(place);
final weekdayDescriptions = _weekdayDescriptions(place);
final typeLabel = _placeTypeLabel(place);
return SafeArea(
top: false,
child: ListView(
controller: widget.scrollController,
padding: const EdgeInsets.fromLTRB(16, 0, 16, 18),
children: [
_PlaceDetailsPhotos(
photoUrls: place.photoUrls,
loading: loadingDetails,
onPhotoTap: (index) =>
widget.onPhotoTap(place.photoUrls, index),
),
const SizedBox(height: 14),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
place.name,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: 0,
),
),
if (typeLabel != null) ...[
const SizedBox(height: 4),
Text(
typeLabel,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
],
),
),
IconButton.filledTonal(
onPressed: _toggleFavorite,
icon: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
),
tooltip: 'Избранное',
),
],
),
const SizedBox(height: 12),
if (snapshot.hasError) ...[
Text(
'Не удалось загрузить детали заведения',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.error,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
],
if (openingSummary != null || weekdayDescriptions.isNotEmpty)
_OpeningHoursTile(
summary: openingSummary,
weekdayDescriptions: weekdayDescriptions,
expanded: _hoursExpanded,
onExpansionChanged: (value) =>
setState(() => _hoursExpanded = value),
),
if (place.traits.isNotEmpty) ...[
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final trait in place.traits)
Chip(
avatar: Icon(trait.icon, size: 16),
label: Text(trait.label),
side: BorderSide(color: tokens.mapPanelBorder),
backgroundColor: tokens.mapPanel,
),
],
),
],
],
),
);
},
);
}
void _toggleFavorite() {
setState(() => _isFavorite = !_isFavorite);
widget.onFavoriteToggle(widget.place);
}
}
class _OpeningHoursTile extends StatelessWidget {
const _OpeningHoursTile({
required this.summary,
required this.weekdayDescriptions,
required this.expanded,
required this.onExpansionChanged,
});
final _OpeningSummary? summary;
final List<String> weekdayDescriptions;
final bool expanded;
final ValueChanged<bool> onExpansionChanged;
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
final colorScheme = Theme.of(context).colorScheme;
final currentSummary = summary;
final title = currentSummary?.label ?? 'Режим работы';
final foreground = currentSummary?.isOpen == true
? colorScheme.primary
: colorScheme.onSurfaceVariant;
return DecoratedBox(
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(tokens.panelRadius),
border: Border.all(color: tokens.mapPanelBorder),
),
child: ExpansionTile(
initiallyExpanded: expanded,
onExpansionChanged: onExpansionChanged,
tilePadding: const EdgeInsets.symmetric(horizontal: 12),
childrenPadding: const EdgeInsets.fromLTRB(12, 0, 12, 12),
leading: Icon(Icons.schedule_outlined, color: foreground),
title: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: foreground, fontWeight: FontWeight.w900),
),
trailing: weekdayDescriptions.isEmpty
? null
: Icon(
expanded
? Icons.expand_less_rounded
: Icons.expand_more_rounded,
),
children: [
if (weekdayDescriptions.isNotEmpty)
for (final line in weekdayDescriptions)
Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
line,
style: Theme.of(context).textTheme.bodyMedium,
),
),
),
],
),
);
}
}
class _PlaceDetailsPhotos extends StatelessWidget {
const _PlaceDetailsPhotos({
required this.photoUrls,
required this.loading,
required this.onPhotoTap,
});
final List<String> photoUrls;
final bool loading;
final ValueChanged<int> onPhotoTap;
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
final colorScheme = Theme.of(context).colorScheme;
if (photoUrls.isEmpty) {
return AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(
decoration: BoxDecoration(
color: tokens.mapPanelBorder,
borderRadius: BorderRadius.circular(tokens.panelRadius),
),
child: Center(
child: loading
? SizedBox.square(
dimension: 28,
child: CircularProgressIndicator(
strokeWidth: 2.4,
color: colorScheme.primary,
),
)
: Icon(
Icons.place_outlined,
color: colorScheme.onSurfaceVariant,
size: 40,
),
),
),
);
}
return SizedBox(
height: 210,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: photoUrls.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, index) {
return GestureDetector(
onTap: () => onPhotoTap(index),
child: AspectRatio(
aspectRatio: 4 / 3,
child: ClipRRect(
borderRadius: BorderRadius.circular(tokens.panelRadius),
child: Image.network(
_photoUrlForSize(photoUrls[index], maxWidth: 720),
fit: BoxFit.cover,
gaplessPlayback: true,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) {
return child;
}
return ColoredBox(
color: tokens.mapPanelBorder,
child: Center(
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.primary,
),
),
),
);
},
errorBuilder: (_, _, _) => ColoredBox(
color: tokens.mapPanelBorder,
child: Icon(
Icons.broken_image_outlined,
color: colorScheme.outline,
),
),
),
),
),
);
},
),
);
}
}
class _PlacePhotoViewer extends StatelessWidget {
const _PlacePhotoViewer({
required this.photoUrls,
required this.initialIndex,
});
final List<String> photoUrls;
final int initialIndex;
@override
Widget build(BuildContext context) {
final controller = PageController(initialPage: initialIndex);
return Dialog.fullscreen(
backgroundColor: Colors.black,
child: Stack(
children: [
PageView.builder(
controller: controller,
itemCount: photoUrls.length,
itemBuilder: (context, index) {
return InteractiveViewer(
minScale: 1,
maxScale: 4,
child: Center(
child: Image.network(
_photoUrlForSize(photoUrls[index], maxWidth: 1600),
fit: BoxFit.contain,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) {
return child;
}
return const CircularProgressIndicator();
},
errorBuilder: (_, _, _) => const Icon(
Icons.broken_image_outlined,
color: Colors.white,
size: 42,
),
),
),
);
},
),
SafeArea(
child: Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(8),
child: IconButton.filled(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
tooltip: 'Закрыть',
),
),
),
),
],
),
);
}
}
class _OpeningSummary {
const _OpeningSummary({required this.label, required this.isOpen});
final String label;
final bool isOpen;
}
class _OpeningPoint {
const _OpeningPoint({
required this.googleDay,
required this.hour,
required this.minute,
});
final int googleDay;
final int hour;
final int minute;
}
_OpeningSummary? _openingSummary(PlaceRecommendation place) {
final openNow = _openNow(place);
final periods = _periods(place);
final now = DateTime.now();
if (openNow == true) {
final closeAt = _nextOpeningBoundary(
periods,
now,
boundary: _OpeningBoundary.close,
);
if (closeAt != null) {
return _OpeningSummary(
label: 'Открыто до ${_formatTime(closeAt)}',
isOpen: true,
);
}
return const _OpeningSummary(label: 'Открыто', isOpen: true);
}
if (openNow == false) {
final openAt = _nextOpeningBoundary(
periods,
now,
boundary: _OpeningBoundary.open,
);
if (openAt != null) {
return _OpeningSummary(
label: 'Откроется ${_formatRelativeTime(openAt, now)}',
isOpen: false,
);
}
return const _OpeningSummary(label: 'Закрыто', isOpen: false);
}
return null;
}
bool? _openNow(PlaceRecommendation place) {
final value = place.googleCurrentOpeningHours?['openNow'];
if (value is bool) {
return value;
}
final legacyValue = place.googleCurrentOpeningHours?['open_now'];
if (legacyValue is bool) {
return legacyValue;
}
return null;
}
enum _OpeningBoundary { open, close }
DateTime? _nextOpeningBoundary(
List<Map<String, dynamic>> periods,
DateTime now, {
required _OpeningBoundary boundary,
}) {
DateTime? next;
for (final period in periods) {
final point = _openingPoint(period[boundary.name]);
if (point == null) {
continue;
}
final candidate = _nextDateForOpeningPoint(point, now);
if (candidate.isBefore(now)) {
continue;
}
if (next == null || candidate.isBefore(next)) {
next = candidate;
}
}
return next;
}
DateTime _nextDateForOpeningPoint(_OpeningPoint point, DateTime now) {
final todayGoogleDay = now.weekday % 7;
var daysUntil = (point.googleDay - todayGoogleDay) % 7;
var candidate = DateTime(
now.year,
now.month,
now.day,
point.hour,
point.minute,
).add(Duration(days: daysUntil));
if (!candidate.isAfter(now)) {
candidate = candidate.add(const Duration(days: 7));
}
return candidate;
}
List<Map<String, dynamic>> _periods(PlaceRecommendation place) {
final current = place.googleCurrentOpeningHours?['periods'];
final regular = place.googleRegularOpeningHours?['periods'];
final source = current is List ? current : regular;
if (source is! List) {
return const [];
}
return [
for (final item in source)
if (item is Map) item.map((key, value) => MapEntry('$key', value)),
];
}
_OpeningPoint? _openingPoint(Object? value) {
if (value is! Map) {
return null;
}
final day = _intValue(value['day']);
final time = value['time'];
final hour = _intValue(value['hour']);
final minute = _intValue(value['minute']) ?? 0;
if (day == null) {
return null;
}
if (hour != null) {
return _OpeningPoint(
googleDay: day,
hour: hour.clamp(0, 23),
minute: minute.clamp(0, 59),
);
}
if (time is String && time.length >= 4) {
final parsedHour = int.tryParse(time.substring(0, 2));
final parsedMinute = int.tryParse(time.substring(2, 4));
if (parsedHour != null && parsedMinute != null) {
return _OpeningPoint(
googleDay: day,
hour: parsedHour.clamp(0, 23),
minute: parsedMinute.clamp(0, 59),
);
}
}
return null;
}
int? _intValue(Object? value) {
if (value is int) {
return value;
}
if (value is num) {
return value.toInt();
}
if (value is String) {
return int.tryParse(value);
}
return null;
}
String _formatRelativeTime(DateTime date, DateTime now) {
final difference = date.difference(now);
if (difference.inMinutes > 0 && difference.inMinutes < 60) {
return 'через ${difference.inMinutes} мин';
}
if (difference.inHours > 0 && difference.inHours < 6) {
return 'через ${difference.inHours} ч';
}
final isToday = _sameDate(date, now);
if (isToday) {
return ${_formatTime(date)}';
}
final tomorrow = now.add(const Duration(days: 1));
if (_sameDate(date, tomorrow)) {
return 'завтра в ${_formatTime(date)}';
}
return '${_weekdayShort(date)} в ${_formatTime(date)}';
}
bool _sameDate(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
}
String _formatTime(DateTime date) {
final hour = date.hour.toString().padLeft(2, '0');
final minute = date.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}
String _weekdayShort(DateTime date) {
return switch (date.weekday) {
DateTime.monday => 'пн',
DateTime.tuesday => 'вт',
DateTime.wednesday => 'ср',
DateTime.thursday => 'чт',
DateTime.friday => 'пт',
DateTime.saturday => 'сб',
_ => 'вс',
};
}
List<String> _weekdayDescriptions(PlaceRecommendation place) {
final current = _stringList(
place.googleCurrentOpeningHours?['weekdayDescriptions'],
);
if (current.isNotEmpty) {
return current;
}
final regular = _stringList(
place.googleRegularOpeningHours?['weekdayDescriptions'],
);
if (regular.isNotEmpty) {
return regular;
}
final legacy = _stringList(place.googleRegularOpeningHours?['weekday_text']);
if (legacy.isNotEmpty) {
return legacy;
}
return const [];
}
List<String> _stringList(Object? value) {
if (value is! List) {
return const [];
}
return [
for (final item in value)
if (item is String && item.trim().isNotEmpty) item,
];
}
String _photoUrlForSize(String url, {required int maxWidth}) {
final uri = Uri.tryParse(url);
if (uri == null || !uri.hasScheme) {
return url;
}
final params = Map<String, String>.from(uri.queryParameters);
if (params.containsKey('maxWidthPx') || params.containsKey('maxHeightPx')) {
params
..update('maxWidthPx', (_) => '$maxWidth', ifAbsent: () => '$maxWidth')
..remove('maxHeightPx');
return uri.replace(queryParameters: params).toString();
}
if (params.containsKey('maxwidth') || params.containsKey('maxheight')) {
params
..update('maxwidth', (_) => '$maxWidth', ifAbsent: () => '$maxWidth')
..remove('maxheight');
return uri.replace(queryParameters: params).toString();
}
if (uri.host.contains('places.googleapis.com')) {
params['maxWidthPx'] = '$maxWidth';
return uri.replace(queryParameters: params).toString();
}
if (uri.host.contains('googleapis.com')) {
params['maxwidth'] = '$maxWidth';
return uri.replace(queryParameters: params).toString();
}
return url;
}
String? _placeTypeLabel(PlaceRecommendation place) {
final raw =
place.googlePrimaryType ??
(place.googleTypes.isEmpty ? null : place.googleTypes.first);
if (raw == null || raw.trim().isEmpty) {
return null;
}
return raw.replaceAll('_', ' ');
}
class AddExperienceFlow extends StatefulWidget {
const AddExperienceFlow({super.key, required this.coordinate});
final LatLng? coordinate;
@override
State<AddExperienceFlow> createState() => _AddExperienceFlowState();
}
class _AddExperienceFlowState extends State<AddExperienceFlow> {
static const _minimumInformationUnits = 16.0;
static const _nearbyPlaceRadiusMeters = 50;
static const _nearbyPlacesTimeout = Duration(seconds: 12);
static const _voicePromptHints = [
'атмосфера',
'еда',
'сервис',
'люди',
'шум',
'цены',
];
final _waveController = WaveformRecorderController(
interval: const Duration(milliseconds: 45),
config: const RecordConfig(
numChannels: 1,
sampleRate: 44100,
autoGain: true,
echoCancel: true,
noiseSuppress: true,
),
);
Future<List<PlaceRecommendation>>? _nearbyPlacesFuture;
StreamSubscription<Amplitude>? _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;
PlaceRecommendation? _selectedPlaceForSubmit;
@override
void initState() {
super.initState();
}
@override
void dispose() {
_amplitudeSub?.cancel();
_waveController.dispose();
super.dispose();
}
Future<void> _toggleRecording() async {
if (_recording) {
await _stopRecording();
return;
}
await _startRecording();
}
Future<List<PlaceRecommendation>> _loadNearbyPlaces(LatLng coordinate) async {
return context
.read<PlacesRepository>()
.fetchNearbyPlaces(
coordinate: coordinate,
radiusMeters: _nearbyPlaceRadiusMeters,
)
.timeout(_nearbyPlacesTimeout);
}
Future<void> _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<void> _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,
);
});
context.read<PlaceCubit>().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 tokens = context.mapflowTokens;
final controller = context.read<PlaceCubit>();
final locationState = context.select((PlaceCubit cubit) {
final state = cubit.state.placeState;
return (coordinate: state?.userCoordinate, status: state?.locationStatus);
});
final effectiveCoordinate = widget.coordinate ?? locationState.coordinate;
final coordinateResolving =
effectiveCoordinate == null &&
locationState.status == LocationResolutionStatus.resolving;
final hasTelegramAuth = context.select(
(PlaceCubit cubit) => cubit.state.placeState?.hasTelegramAuth ?? false,
);
if (_step == 1 &&
_nearbyPlacesFuture == null &&
effectiveCoordinate != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _step != 1 || _nearbyPlacesFuture != null) {
return;
}
setState(() {
_nearbyPlacesFuture = _loadNearbyPlaces(effectiveCoordinate);
});
});
}
final informationProgress = (_informationUnits / _minimumInformationUnits)
.clamp(0.0, 1.0);
final content = switch (_step) {
0 => _VoiceStep(
placeName: '',
promptHints: _voicePromptHints,
hasTelegramAuth: hasTelegramAuth,
informationProgress: informationProgress,
isRecording: _recording,
isSubmitting: _submitting,
micAllowed: _micAllowed,
liveLevel: _liveLevel,
canContinue: hasTelegramAuth && informationProgress >= 1,
onToggleRecording: _toggleRecording,
onNext: () async {
if (_recording) {
await _stopRecording();
}
setState(() {
_nearbyPlacesFuture = effectiveCoordinate == null
? null
: _loadNearbyPlaces(effectiveCoordinate);
_step = 1;
});
},
),
1 => _PlaceStep(
placesFuture: _nearbyPlacesFuture,
hasCoordinate: effectiveCoordinate != null,
coordinateResolving: coordinateResolving,
radiusMeters: _nearbyPlaceRadiusMeters,
isSubmitting: _submitting,
onRetry: () {
setState(() {
_nearbyPlacesFuture = effectiveCoordinate == null
? null
: _loadNearbyPlaces(effectiveCoordinate);
});
},
onSelect: (place) async {
controller.setReviewPlace(place.name);
setState(() {
_selectedPlaceForSubmit = place;
_step = 2;
});
},
),
_ => const SizedBox.shrink(),
};
final contentWithFavoriteStep = _step == 2
? _FavoriteStep(
place: _selectedPlaceForSubmit,
isSubmitting: _submitting,
onSubmit: _submitSelectedPlace,
)
: content;
return Scaffold(
backgroundColor: tokens.darkSurface,
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: () => context.pop(),
),
const SizedBox(height: 18),
Expanded(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
child: KeyedSubtree(
key: ValueKey(_step),
child: contentWithFavoriteStep,
),
),
),
],
),
),
),
);
}
Future<void> _submitSelectedPlace(bool addToFavorites) async {
final place = _selectedPlaceForSubmit;
if (place == null) {
throw StateError('Place is required to publish a review.');
}
setState(() => _submitting = true);
final controller = context.read<PlaceCubit>();
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',
addToFavorites: addToFavorites,
promptHintsShown: _voicePromptHints,
recordingPayload: {
'source': 'web',
'nearbyPlaceRadiusMeters': _nearbyPlaceRadiusMeters,
'informationUnits': _informationUnits,
'minimumInformationUnits': _minimumInformationUnits,
'selectedPlace': {
'googlePlaceId': place.googlePlaceId,
'name': place.name,
'googlePrimaryType': place.googlePrimaryType,
'googleTypes': place.googleTypes,
'latitude': place.coordinate.latitude,
'longitude': place.coordinate.longitude,
},
},
);
if (!mounted) {
return;
}
context.pop();
}
}
class _PlaceStep extends StatelessWidget {
const _PlaceStep({
required this.placesFuture,
required this.hasCoordinate,
required this.coordinateResolving,
required this.radiusMeters,
required this.isSubmitting,
required this.onRetry,
required this.onSelect,
});
final Future<List<PlaceRecommendation>>? placesFuture;
final bool hasCoordinate;
final bool coordinateResolving;
final int radiusMeters;
final bool isSubmitting;
final VoidCallback onRetry;
final Future<void> Function(PlaceRecommendation) onSelect;
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
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: placesFuture == null
? coordinateResolving
? Center(
child: CircularProgressIndicator(
color: tokens.voiceAccent,
),
)
: _PlaceUnavailable(
icon: Icons.location_off_outlined,
message: 'Нет геопозиции',
tokens: tokens,
)
: FutureBuilder<List<PlaceRecommendation>>(
future: placesFuture,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return Center(
child: CircularProgressIndicator(
color: tokens.voiceAccent,
),
);
}
if (snapshot.hasError) {
return _PlaceUnavailable(
icon: Icons.error_outline,
message: 'Места не загрузились',
tokens: tokens,
action: TextButton(
onPressed: onRetry,
child: const Text('Повторить'),
),
);
}
final places =
snapshot.data ?? const <PlaceRecommendation>[];
if (places.isEmpty) {
return _PlaceUnavailable(
icon: Icons.location_off_outlined,
message: hasCoordinate
? 'Нет мест в $radiusMetersм'
: 'Нет геопозиции',
tokens: tokens,
);
}
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 _PlaceUnavailable extends StatelessWidget {
const _PlaceUnavailable({
required this.icon,
required this.message,
required this.tokens,
this.action,
});
final IconData icon;
final String message;
final MapflowThemeTokens tokens;
final Widget? action;
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: tokens.onDark, size: 42),
const SizedBox(height: 10),
Text(message, style: TextStyle(color: tokens.onDark)),
if (action != null) ...[const SizedBox(height: 8), action!],
],
),
);
}
}
class _FavoriteStep extends StatelessWidget {
const _FavoriteStep({
required this.place,
required this.isSubmitting,
required this.onSubmit,
});
final PlaceRecommendation? place;
final bool isSubmitting;
final Future<void> Function(bool) onSubmit;
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
final selectedPlace = place;
if (selectedPlace == null) {
return const SizedBox.shrink();
}
return _StepLayout(
body: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.favorite_border, color: tokens.onDark, size: 48),
const SizedBox(height: 18),
Text(
selectedPlace.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: tokens.onDark,
fontWeight: FontWeight.w900,
letterSpacing: 0,
),
),
const SizedBox(height: 26),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: isSubmitting ? null : () => onSubmit(false),
child: const Text('Не сейчас'),
),
),
const SizedBox(width: 10),
Expanded(
child: FilledButton(
onPressed: isSubmitting ? null : () => onSubmit(true),
child: isSubmitting
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('В избранное'),
),
),
],
),
],
),
);
}
}
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);
final tokens = context.mapflowTokens;
return Material(
color: tokens.darkSurfaceAlt,
borderRadius: BorderRadius.circular(tokens.panelRadius),
child: InkWell(
onTap: disabled ? null : onTap,
borderRadius: BorderRadius.circular(tokens.panelRadius),
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.place_outlined,
color: tokens.onDark.withValues(alpha: 0.70),
),
const SizedBox(width: 10),
Expanded(
child: Text(
place.name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: tokens.onDark,
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) {
final tokens = context.mapflowTokens;
return DecoratedBox(
decoration: BoxDecoration(
color: primary
? tokens.voiceAccent
: tokens.onDark.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(tokens.panelRadius),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
child: Text(
label,
style: TextStyle(
color: primary
? tokens.onDark
: tokens.onDark.withValues(alpha: 0.92),
fontSize: 12,
fontWeight: FontWeight.w700,
height: 1,
),
),
),
);
}
}
class _VoiceStep extends StatelessWidget {
const _VoiceStep({
required this.placeName,
required this.promptHints,
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 List<String> promptHints;
final bool hasTelegramAuth;
final double informationProgress;
final bool isRecording;
final bool isSubmitting;
final bool micAllowed;
final double liveLevel;
final bool canContinue;
final Future<void> Function() onToggleRecording;
final VoidCallback onNext;
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
final canFinish = canContinue;
return Stack(
children: [
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)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Icon(
Icons.mic_off_outlined,
color: tokens.voiceAccentSoft,
size: 22,
),
),
_VoiceRecordButton(
progress: informationProgress,
liveLevel: liveLevel,
isRecording: isRecording,
canFinish: canFinish,
enabled: hasTelegramAuth && !isSubmitting,
onPressed: canFinish ? onNext : onToggleRecording,
),
],
),
Positioned.fill(
bottom: 230,
child: _FloatingPromptHints(
hints: promptHints,
active: isRecording || informationProgress < 1,
),
),
],
);
}
}
class _FloatingPromptHints extends StatefulWidget {
const _FloatingPromptHints({required this.hints, required this.active});
final List<String> hints;
final bool active;
@override
State<_FloatingPromptHints> createState() => _FloatingPromptHintsState();
}
class _FloatingPromptHintsState extends State<_FloatingPromptHints>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 13500),
);
_syncAnimation();
}
@override
void didUpdateWidget(covariant _FloatingPromptHints oldWidget) {
super.didUpdateWidget(oldWidget);
_syncAnimation();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _syncAnimation() {
if (widget.active && !_controller.isAnimating) {
_controller.repeat();
}
if (!widget.active && _controller.isAnimating) {
_controller.stop();
}
}
@override
Widget build(BuildContext context) {
if (widget.hints.isEmpty) {
return const SizedBox.shrink();
}
return IgnorePointer(
child: AnimatedOpacity(
duration: const Duration(milliseconds: 260),
opacity: widget.active ? 1 : 0,
child: AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return LayoutBuilder(
builder: (context, constraints) {
return Stack(
fit: StackFit.expand,
children: [
for (var index = 0; index < widget.hints.length; index++)
_FloatingPromptHint(
label: widget.hints[index],
index: index,
progress: _controller.value,
size: constraints.biggest,
),
],
);
},
);
},
),
),
);
}
}
class _FloatingPromptHint extends StatelessWidget {
const _FloatingPromptHint({
required this.label,
required this.index,
required this.progress,
required this.size,
});
final String label;
final int index;
final double progress;
final Size size;
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
final phase = (progress + index * 0.173) % 1.0;
final eased = Curves.easeInOutCubic.transform(phase);
final visibility = _visibilityForPhase(phase);
final seed = index + 1.0;
final start = _alignment(seed, 0);
final end = _alignment(seed, 1);
final alignment = Alignment(
_lerpDouble(start.x, end.x, eased),
_lerpDouble(start.y, end.y, eased),
);
final drift = Offset(
math.sin((phase * math.pi * 2) + seed) * size.width * 0.035,
math.cos((phase * math.pi * 2) + seed * 1.9) * size.height * 0.026,
);
final scale =
0.76 +
visibility * 0.28 +
math.sin((phase * math.pi * 2) + seed * 2.4) * 0.035;
final rotation = math.sin((phase * math.pi * 2) + seed) * 0.035;
return Align(
alignment: alignment,
child: Transform.translate(
offset: drift,
child: Transform.rotate(
angle: rotation,
child: Transform.scale(
scale: scale,
child: Opacity(
opacity: visibility,
child: DecoratedBox(
decoration: BoxDecoration(
color: tokens.voiceAccent.withValues(alpha: 0.88),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: tokens.onDark.withValues(alpha: 0.16),
),
boxShadow: [
BoxShadow(
color: tokens.voiceAccent.withValues(alpha: 0.30),
blurRadius: 26,
spreadRadius: 1,
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 13,
vertical: 8,
),
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: tokens.onVoiceControl,
fontWeight: FontWeight.w900,
letterSpacing: 0,
height: 1,
),
),
),
),
),
),
),
),
);
}
double _visibilityForPhase(double phase) {
if (phase < 0.16) {
return Curves.easeOutCubic.transform(phase / 0.16);
}
if (phase > 0.70) {
return Curves.easeInCubic.transform((1 - phase) / 0.30);
}
return 1;
}
Alignment _alignment(double seed, int salt) {
final xSeed = math.sin(seed * 19.73 + salt * 41.11) * 10000;
final ySeed = math.sin(seed * 47.31 + salt * 17.17) * 10000;
final x = ((xSeed - xSeed.floor()) * 1.76 - 0.88).clamp(-0.88, 0.88);
final y = ((ySeed - ySeed.floor()) * 1.58 - 0.79).clamp(-0.79, 0.79);
return Alignment(x, y);
}
double _lerpDouble(double a, double b, double t) => a + (b - a) * t;
}
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) {
final tokens = context.mapflowTokens;
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
width: size,
height: size,
decoration: BoxDecoration(
color: filled
? tokens.voiceAccent
: tokens.onDark.withValues(alpha: 0.11),
borderRadius: BorderRadius.circular(3),
boxShadow: filled
? [
BoxShadow(
color: tokens.voiceAccent.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) {
final tokens = context.mapflowTokens;
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: tokens.voiceAccent.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: tokens.voiceAccent,
backgroundColor: tokens.onDark.withValues(alpha: 0.12),
),
),
child!,
],
);
},
child: SizedBox(
width: 156,
height: 156,
child: FilledButton(
onPressed: widget.enabled ? widget.onPressed : null,
style: FilledButton.styleFrom(
backgroundColor: tokens.onDark,
foregroundColor: tokens.onVoiceControl,
disabledBackgroundColor: tokens.onDark.withValues(alpha: 0.28),
disabledForegroundColor: tokens.onDark.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});
final Widget body;
@override
Widget build(BuildContext context) {
return Column(children: [Expanded(child: body)]);
}
}
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) {
final tokens = context.mapflowTokens;
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
? tokens.voiceAccent
: Theme.of(context).colorScheme.primary)
: (dark
? tokens.onDark.withValues(alpha: 0.16)
: tokens.mapPanelBorder),
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: 'Закрыть',
),
],
);
}
}