Tune map controls and place details
Build and deploy Flutter Web / build (push) Successful in 3m47s

This commit is contained in:
Ruslan Bakiev
2026-06-22 12:56:54 +07:00
parent 28467eba5d
commit 60f4844a4a
3 changed files with 441 additions and 170 deletions
@@ -152,9 +152,14 @@ class PlaceCubit extends Cubit<PlaceViewState> {
return; return;
} }
final currentUser = await _authRepository.authenticateTelegram(); final results = await Future.wait<Object?>([
final userCoordinate = await _location.resolve(); _authRepository.authenticateTelegram(),
final places = await _placesRepository.fetchPlaces(); _location.resolve(),
_placesRepository.fetchPlaces(),
]);
final currentUser = results[0] as AppUser;
final userCoordinate = results[1] as LatLng?;
final places = results[2] as List<PlaceRecommendation>;
emit( emit(
PlaceViewState.ready( PlaceViewState.ready(
PlaceState( PlaceState(
@@ -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( SafeArea(
child: Align( child: Align(
alignment: Alignment.topRight, alignment: Alignment.topRight,
@@ -171,11 +158,17 @@ class _MapContent extends StatelessWidget {
), ),
), ),
Align( Align(
alignment: Alignment.bottomLeft, alignment: Alignment.bottomCenter,
child: SafeArea( child: SafeArea(
top: false, top: false,
child: _SearchLauncherButton( child: _MapBottomControls(
onPressed: () => _openSearchSheet( activeRecommendationLabel: activeRecommendationLabel,
onClearRecommendation: activeRecommendationLabel == null
? null
: hasVoiceRecommendation
? () => unawaited(placeCubit.clearVoiceFilter())
: placeCubit.clearTrait,
onSearch: () => _openSearchSheet(
context, context,
traits: availableTraits, traits: availableTraits,
selectedTrait: state.selectedTrait, selectedTrait: state.selectedTrait,
@@ -408,7 +401,7 @@ class _AddReviewAction extends StatelessWidget {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Padding( return Padding(
padding: const EdgeInsets.only(top: 62, right: 12), padding: const EdgeInsets.only(top: 8, right: 12),
child: FilledButton.icon( child: FilledButton.icon(
onPressed: onPressed, onPressed: onPressed,
icon: const Icon(Icons.rate_review_outlined, size: 18), 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 { class _ActiveRecommendationChip extends StatelessWidget {
const _ActiveRecommendationChip({required this.label, required this.onClear}); const _ActiveRecommendationChip({required this.label, required this.onClear});
@@ -434,9 +463,7 @@ class _ActiveRecommendationChip extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = context.mapflowTokens; final tokens = context.mapflowTokens;
return Padding( return Material(
padding: const EdgeInsets.only(top: 62),
child: Material(
color: tokens.mapPanel, color: tokens.mapPanel,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(tokens.panelRadius), borderRadius: BorderRadius.circular(tokens.panelRadius),
@@ -449,7 +476,14 @@ class _ActiveRecommendationChip extends StatelessWidget {
children: [ children: [
const Icon(Icons.travel_explore_rounded, size: 18), const Icon(Icons.travel_explore_rounded, size: 18),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(label, style: const TextStyle(fontWeight: FontWeight.w800)), Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.w800),
),
),
const SizedBox(width: 4), const SizedBox(width: 4),
IconButton( IconButton(
onPressed: onClear, onPressed: onClear,
@@ -464,7 +498,6 @@ class _ActiveRecommendationChip extends StatelessWidget {
], ],
), ),
), ),
),
); );
} }
} }
@@ -479,9 +512,7 @@ class _SearchLauncherButton extends StatelessWidget {
final tokens = context.mapflowTokens; final tokens = context.mapflowTokens;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Padding( return FloatingActionButton.extended(
padding: const EdgeInsets.fromLTRB(12, 0, 0, 12),
child: FloatingActionButton.extended(
heroTag: 'map-search', heroTag: 'map-search',
onPressed: onPressed, onPressed: onPressed,
icon: const Icon(Icons.search_rounded), icon: const Icon(Icons.search_rounded),
@@ -492,7 +523,6 @@ class _SearchLauncherButton extends StatelessWidget {
borderRadius: BorderRadius.circular(tokens.panelRadius), borderRadius: BorderRadius.circular(tokens.panelRadius),
side: BorderSide(color: tokens.mapPanelBorder), side: BorderSide(color: tokens.mapPanelBorder),
), ),
),
); );
} }
} }
@@ -924,7 +954,7 @@ class _TraitPickerSheet extends StatelessWidget {
return SafeArea( return SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 18), padding: const EdgeInsets.fromLTRB(16, 18, 16, 18),
child: Wrap( child: Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
@@ -1037,12 +1067,13 @@ class _PlaceDetailsSheet extends StatefulWidget {
class _PlaceDetailsSheetState extends State<_PlaceDetailsSheet> { class _PlaceDetailsSheetState extends State<_PlaceDetailsSheet> {
late var _isFavorite = widget.place.isFavorite; late var _isFavorite = widget.place.isFavorite;
var _hoursExpanded = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final tokens = context.mapflowTokens; final tokens = context.mapflowTokens;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final openingLabel = _openingLabel(widget.place); final openingSummary = _openingSummary(widget.place);
final weekdayDescriptions = _weekdayDescriptions(widget.place); final weekdayDescriptions = _weekdayDescriptions(widget.place);
final typeLabel = _placeTypeLabel(widget.place); final typeLabel = _placeTypeLabel(widget.place);
@@ -1096,29 +1127,13 @@ class _PlaceDetailsSheetState extends State<_PlaceDetailsSheet> {
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Wrap( if (openingSummary != null || weekdayDescriptions.isNotEmpty)
spacing: 8, _OpeningHoursTile(
runSpacing: 8, summary: openingSummary,
children: [ weekdayDescriptions: weekdayDescriptions,
if (widget.place.googleRating != null) expanded: _hoursExpanded,
_InfoChip( onExpansionChanged: (value) =>
icon: Icons.star_rounded, setState(() => _hoursExpanded = value),
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 (widget.place.traits.isNotEmpty) ...[ if (widget.place.traits.isNotEmpty) ...[
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -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<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 { class _PlaceDetailsPhotos extends StatelessWidget {
const _PlaceDetailsPhotos({ const _PlaceDetailsPhotos({
required this.photoUrls, required this.photoUrls,
@@ -1226,7 +1274,7 @@ class _PlaceDetailsPhotos extends StatelessWidget {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(tokens.panelRadius), borderRadius: BorderRadius.circular(tokens.panelRadius),
child: Image.network( child: Image.network(
photoUrls[index], _photoUrlForSize(photoUrls[index], maxWidth: 720),
fit: BoxFit.cover, fit: BoxFit.cover,
gaplessPlayback: true, gaplessPlayback: true,
loadingBuilder: (context, child, loadingProgress) { loadingBuilder: (context, child, loadingProgress) {
@@ -1288,7 +1336,7 @@ class _PlacePhotoViewer extends StatelessWidget {
maxScale: 4, maxScale: 4,
child: Center( child: Center(
child: Image.network( child: Image.network(
photoUrls[index], _photoUrlForSize(photoUrls[index], maxWidth: 1600),
fit: BoxFit.contain, fit: BoxFit.contain,
loadingBuilder: (context, child, loadingProgress) { loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) { if (loadingProgress == null) {
@@ -1325,59 +1373,218 @@ class _PlacePhotoViewer extends StatelessWidget {
} }
} }
class _InfoChip extends StatelessWidget { class _OpeningSummary {
const _InfoChip({ const _OpeningSummary({required this.label, required this.isOpen});
required this.icon,
required this.label, final String label;
this.highlighted = false, final bool isOpen;
}
class _OpeningPoint {
const _OpeningPoint({
required this.googleDay,
required this.hour,
required this.minute,
}); });
final IconData icon; final int googleDay;
final String label; final int hour;
final bool highlighted; final int minute;
}
@override _OpeningSummary? _openingSummary(PlaceRecommendation place) {
Widget build(BuildContext context) { final openNow = _openNow(place);
final colorScheme = Theme.of(context).colorScheme; final periods = _periods(place);
final foreground = highlighted final now = DateTime.now();
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant; if (openNow == true) {
return Chip( final closeAt = _nextOpeningBoundary(
avatar: Icon(icon, size: 16, color: foreground), periods,
label: Text(label), now,
labelStyle: TextStyle(color: foreground, fontWeight: FontWeight.w700), boundary: _OpeningBoundary.close,
side: BorderSide.none, );
backgroundColor: highlighted if (closeAt != null) {
? colorScheme.primaryContainer return _OpeningSummary(
: colorScheme.surfaceContainerHighest, 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) { bool? _openNow(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) {
final value = place.googleCurrentOpeningHours?['openNow']; final value = place.googleCurrentOpeningHours?['openNow'];
if (value is bool) { if (value is bool) {
return value ? 'Открыто сейчас' : 'Сейчас закрыто'; return value;
} }
final legacyValue = place.googleCurrentOpeningHours?['open_now']; final legacyValue = place.googleCurrentOpeningHours?['open_now'];
if (legacyValue is bool) { if (legacyValue is bool) {
return legacyValue ? 'Открыто сейчас' : 'Сейчас закрыто'; return legacyValue;
} }
return null; 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) { List<String> _weekdayDescriptions(PlaceRecommendation place) {
final current = _stringList( final current = _stringList(
place.googleCurrentOpeningHours?['weekdayDescriptions'], place.googleCurrentOpeningHours?['weekdayDescriptions'],
@@ -1408,6 +1615,40 @@ List<String> _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<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) { String? _placeTypeLabel(PlaceRecommendation place) {
final raw = final raw =
place.googlePrimaryType ?? place.googlePrimaryType ??
@@ -1418,15 +1659,6 @@ String? _placeTypeLabel(PlaceRecommendation place) {
return raw.replaceAll('_', ' '); return raw.replaceAll('_', ' ');
} }
String _businessStatusLabel(String status) {
return switch (status) {
'OPERATIONAL' => 'Работает',
'CLOSED_TEMPORARILY' => 'Временно закрыто',
'CLOSED_PERMANENTLY' => 'Закрыто',
_ => status.replaceAll('_', ' ').toLowerCase(),
};
}
class AddExperienceFlow extends StatefulWidget { class AddExperienceFlow extends StatefulWidget {
const AddExperienceFlow({super.key, required this.coordinate}); const AddExperienceFlow({super.key, required this.coordinate});
@@ -153,7 +153,7 @@ class _PhotoTile extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
return Image.network( return Image.network(
url, _photoUrlForSize(url, maxWidth: 512),
fit: BoxFit.cover, fit: BoxFit.cover,
gaplessPlayback: true, gaplessPlayback: true,
loadingBuilder: (context, child, loadingProgress) { 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<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;
}