This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _ratingLabel(PlaceRecommendation place) {
|
return null;
|
||||||
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) {
|
int? _intValue(Object? value) {
|
||||||
final value = place.googleCurrentOpeningHours?['openNow'];
|
if (value is int) {
|
||||||
if (value is bool) {
|
return value;
|
||||||
return value ? 'Открыто сейчас' : 'Сейчас закрыто';
|
|
||||||
}
|
}
|
||||||
final legacyValue = place.googleCurrentOpeningHours?['open_now'];
|
if (value is num) {
|
||||||
if (legacyValue is bool) {
|
return value.toInt();
|
||||||
return legacyValue ? 'Открыто сейчас' : 'Сейчас закрыто';
|
}
|
||||||
|
if (value is String) {
|
||||||
|
return int.tryParse(value);
|
||||||
}
|
}
|
||||||
return null;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user