3550 lines
102 KiB
Dart
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: 'Закрыть',
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|