This commit is contained in:
@@ -152,9 +152,14 @@ class PlaceCubit extends Cubit<PlaceViewState> {
|
||||
return;
|
||||
}
|
||||
|
||||
final currentUser = await _authRepository.authenticateTelegram();
|
||||
final userCoordinate = await _location.resolve();
|
||||
final places = await _placesRepository.fetchPlaces();
|
||||
final results = await Future.wait<Object?>([
|
||||
_authRepository.authenticateTelegram(),
|
||||
_location.resolve(),
|
||||
_placesRepository.fetchPlaces(),
|
||||
]);
|
||||
final currentUser = results[0] as AppUser;
|
||||
final userCoordinate = results[1] as LatLng?;
|
||||
final places = results[2] as List<PlaceRecommendation>;
|
||||
emit(
|
||||
PlaceViewState.ready(
|
||||
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(
|
||||
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<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,
|
||||
@@ -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<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'],
|
||||
@@ -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) {
|
||||
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});
|
||||
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user