diff --git a/lib/features/mapflow/application/place_cubit.dart b/lib/features/mapflow/application/place_cubit.dart index 2783105..911a186 100644 --- a/lib/features/mapflow/application/place_cubit.dart +++ b/lib/features/mapflow/application/place_cubit.dart @@ -152,9 +152,14 @@ class PlaceCubit extends Cubit { return; } - final currentUser = await _authRepository.authenticateTelegram(); - final userCoordinate = await _location.resolve(); - final places = await _placesRepository.fetchPlaces(); + final results = await Future.wait([ + _authRepository.authenticateTelegram(), + _location.resolve(), + _placesRepository.fetchPlaces(), + ]); + final currentUser = results[0] as AppUser; + final userCoordinate = results[1] as LatLng?; + final places = results[2] as List; emit( PlaceViewState.ready( PlaceState( diff --git a/lib/features/mapflow/presentation/mapflow_shell.dart b/lib/features/mapflow/presentation/mapflow_shell.dart index e12f76f..64a8e47 100644 --- a/lib/features/mapflow/presentation/mapflow_shell.dart +++ b/lib/features/mapflow/presentation/mapflow_shell.dart @@ -129,19 +129,6 @@ class _MapContent extends StatelessWidget { ), ), ), - SafeArea( - child: Align( - alignment: Alignment.topCenter, - child: activeRecommendationLabel == null - ? const SizedBox.shrink() - : _ActiveRecommendationChip( - label: activeRecommendationLabel, - onClear: hasVoiceRecommendation - ? () => unawaited(placeCubit.clearVoiceFilter()) - : placeCubit.clearTrait, - ), - ), - ), SafeArea( child: Align( alignment: Alignment.topRight, @@ -171,11 +158,17 @@ class _MapContent extends StatelessWidget { ), ), Align( - alignment: Alignment.bottomLeft, + alignment: Alignment.bottomCenter, child: SafeArea( top: false, - child: _SearchLauncherButton( - onPressed: () => _openSearchSheet( + child: _MapBottomControls( + activeRecommendationLabel: activeRecommendationLabel, + onClearRecommendation: activeRecommendationLabel == null + ? null + : hasVoiceRecommendation + ? () => unawaited(placeCubit.clearVoiceFilter()) + : placeCubit.clearTrait, + onSearch: () => _openSearchSheet( context, traits: availableTraits, selectedTrait: state.selectedTrait, @@ -408,7 +401,7 @@ class _AddReviewAction extends StatelessWidget { final colorScheme = Theme.of(context).colorScheme; return Padding( - padding: const EdgeInsets.only(top: 62, right: 12), + padding: const EdgeInsets.only(top: 8, right: 12), child: FilledButton.icon( onPressed: onPressed, icon: const Icon(Icons.rate_review_outlined, size: 18), @@ -424,6 +417,42 @@ class _AddReviewAction extends StatelessWidget { } } +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}); @@ -434,35 +463,39 @@ class _ActiveRecommendationChip extends StatelessWidget { Widget build(BuildContext context) { final tokens = context.mapflowTokens; - return Padding( - padding: const EdgeInsets.only(top: 62), - child: 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), - Text(label, 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, - ), + 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, + ), + ), + ], ), ), ); @@ -479,19 +512,16 @@ class _SearchLauncherButton extends StatelessWidget { final tokens = context.mapflowTokens; final colorScheme = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.fromLTRB(12, 0, 0, 12), - child: 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), - ), + 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), ), ); } @@ -924,7 +954,7 @@ class _TraitPickerSheet extends StatelessWidget { return SafeArea( child: Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 18), + padding: const EdgeInsets.fromLTRB(16, 18, 16, 18), child: Wrap( spacing: 8, runSpacing: 8, @@ -1037,12 +1067,13 @@ class _PlaceDetailsSheet extends StatefulWidget { 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; - final openingLabel = _openingLabel(widget.place); + final openingSummary = _openingSummary(widget.place); final weekdayDescriptions = _weekdayDescriptions(widget.place); final typeLabel = _placeTypeLabel(widget.place); @@ -1096,30 +1127,14 @@ class _PlaceDetailsSheetState extends State<_PlaceDetailsSheet> { ], ), const SizedBox(height: 12), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - if (widget.place.googleRating != null) - _InfoChip( - icon: Icons.star_rounded, - label: _ratingLabel(widget.place), - ), - if (openingLabel != null) - _InfoChip( - icon: Icons.schedule_outlined, - label: openingLabel, - highlighted: openingLabel == 'Открыто сейчас', - ), - if (widget.place.googleBusinessStatus != null) - _InfoChip( - icon: Icons.storefront_outlined, - label: _businessStatusLabel( - widget.place.googleBusinessStatus!, - ), - ), - ], - ), + if (openingSummary != null || weekdayDescriptions.isNotEmpty) + _OpeningHoursTile( + summary: openingSummary, + weekdayDescriptions: weekdayDescriptions, + expanded: _hoursExpanded, + onExpansionChanged: (value) => + setState(() => _hoursExpanded = value), + ), if (widget.place.traits.isNotEmpty) ...[ const SizedBox(height: 16), Wrap( @@ -1136,40 +1151,6 @@ class _PlaceDetailsSheetState extends State<_PlaceDetailsSheet> { ], ), ], - if (weekdayDescriptions.isNotEmpty) ...[ - const SizedBox(height: 18), - Text( - 'Режим работы', - style: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w800), - ), - const SizedBox(height: 8), - DecoratedBox( - decoration: BoxDecoration( - color: colorScheme.surfaceContainerHighest.withValues( - alpha: 0.55, - ), - borderRadius: BorderRadius.circular(tokens.panelRadius), - ), - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final line in weekdayDescriptions) - Padding( - padding: const EdgeInsets.symmetric(vertical: 3), - child: Text( - line, - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - ], - ), - ), - ), - ], ], ), ), @@ -1182,6 +1163,73 @@ class _PlaceDetailsSheetState extends State<_PlaceDetailsSheet> { } } +class _OpeningHoursTile extends StatelessWidget { + const _OpeningHoursTile({ + required this.summary, + required this.weekdayDescriptions, + required this.expanded, + required this.onExpansionChanged, + }); + + final _OpeningSummary? summary; + final List weekdayDescriptions; + final bool expanded; + final ValueChanged 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, @@ -1226,7 +1274,7 @@ class _PlaceDetailsPhotos extends StatelessWidget { child: ClipRRect( borderRadius: BorderRadius.circular(tokens.panelRadius), child: Image.network( - photoUrls[index], + _photoUrlForSize(photoUrls[index], maxWidth: 720), fit: BoxFit.cover, gaplessPlayback: true, loadingBuilder: (context, child, loadingProgress) { @@ -1288,7 +1336,7 @@ class _PlacePhotoViewer extends StatelessWidget { maxScale: 4, child: Center( child: Image.network( - photoUrls[index], + _photoUrlForSize(photoUrls[index], maxWidth: 1600), fit: BoxFit.contain, loadingBuilder: (context, child, loadingProgress) { if (loadingProgress == null) { @@ -1325,59 +1373,218 @@ class _PlacePhotoViewer extends StatelessWidget { } } -class _InfoChip extends StatelessWidget { - const _InfoChip({ - required this.icon, - required this.label, - this.highlighted = false, +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 IconData icon; - final String label; - final bool highlighted; + final int googleDay; + final int hour; + final int minute; +} - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - final foreground = highlighted - ? colorScheme.onPrimaryContainer - : colorScheme.onSurfaceVariant; - return Chip( - avatar: Icon(icon, size: 16, color: foreground), - label: Text(label), - labelStyle: TextStyle(color: foreground, fontWeight: FontWeight.w700), - side: BorderSide.none, - backgroundColor: highlighted - ? colorScheme.primaryContainer - : colorScheme.surfaceContainerHighest, +_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; } -String _ratingLabel(PlaceRecommendation place) { - final rating = place.googleRating?.toStringAsFixed(1); - final count = place.googleUserRatingCount; - if (rating == null) { - return ''; - } - if (count == null || count == 0) { - return rating; - } - return '$rating ($count)'; -} - -String? _openingLabel(PlaceRecommendation place) { +bool? _openNow(PlaceRecommendation place) { final value = place.googleCurrentOpeningHours?['openNow']; if (value is bool) { - return value ? 'Открыто сейчас' : 'Сейчас закрыто'; + return value; } final legacyValue = place.googleCurrentOpeningHours?['open_now']; if (legacyValue is bool) { - return legacyValue ? 'Открыто сейчас' : 'Сейчас закрыто'; + return legacyValue; } return null; } +enum _OpeningBoundary { open, close } + +DateTime? _nextOpeningBoundary( + List> 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> _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 _weekdayDescriptions(PlaceRecommendation place) { final current = _stringList( place.googleCurrentOpeningHours?['weekdayDescriptions'], @@ -1408,6 +1615,40 @@ List _stringList(Object? value) { ]; } +String _photoUrlForSize(String url, {required int maxWidth}) { + final uri = Uri.tryParse(url); + if (uri == null || !uri.hasScheme) { + return url; + } + + final params = Map.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 ?? @@ -1418,15 +1659,6 @@ String? _placeTypeLabel(PlaceRecommendation place) { return raw.replaceAll('_', ' '); } -String _businessStatusLabel(String status) { - return switch (status) { - 'OPERATIONAL' => 'Работает', - 'CLOSED_TEMPORARILY' => 'Временно закрыто', - 'CLOSED_PERMANENTLY' => 'Закрыто', - _ => status.replaceAll('_', ' ').toLowerCase(), - }; -} - class AddExperienceFlow extends StatefulWidget { const AddExperienceFlow({super.key, required this.coordinate}); diff --git a/lib/features/mapflow/presentation/widgets/place_photo_card.dart b/lib/features/mapflow/presentation/widgets/place_photo_card.dart index 4fd96b8..d1d0ae5 100644 --- a/lib/features/mapflow/presentation/widgets/place_photo_card.dart +++ b/lib/features/mapflow/presentation/widgets/place_photo_card.dart @@ -153,7 +153,7 @@ class _PhotoTile extends StatelessWidget { Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; return Image.network( - url, + _photoUrlForSize(url, maxWidth: 512), fit: BoxFit.cover, gaplessPlayback: true, loadingBuilder: (context, child, loadingProgress) { @@ -181,3 +181,37 @@ class _PhotoTile extends StatelessWidget { ); } } + +String _photoUrlForSize(String url, {required int maxWidth}) { + final uri = Uri.tryParse(url); + if (uri == null || !uri.hasScheme) { + return url; + } + + final params = Map.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; +}