Apply dark restaurant theme
Build and deploy Flutter Web / build (push) Successful in 4m44s

This commit is contained in:
Ruslan Bakiev
2026-06-22 11:16:24 +07:00
parent 9d7ed4f6e5
commit 8c23e878bb
5 changed files with 547 additions and 43 deletions
@@ -26,7 +26,7 @@ class _AdminVoiceExperiencesScreenState
final tokens = context.mapflowTokens;
return Scaffold(
backgroundColor: tokens.mapPanel,
backgroundColor: tokens.darkSurface,
appBar: AppBar(title: const Text('Отзывы')),
body: FutureBuilder<List<VoiceExperienceDebug>>(
future: _future,
@@ -64,8 +64,11 @@ class _AdminVoiceExperienceRow extends StatelessWidget {
final tokens = context.mapflowTokens;
return Material(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(tokens.panelRadius),
color: tokens.mapPanel,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(tokens.panelRadius),
side: BorderSide(color: tokens.mapPanelBorder),
),
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
@@ -99,7 +99,7 @@ class _MapContent extends StatelessWidget {
point: place.coordinate,
child: _PlaceMarker(
selected: selected?.id == place.id,
onTap: () => placeCubit.selectPlace(place.id),
onTap: () => _selectAndShowPlace(context, place),
),
),
if (userCoordinate != null)
@@ -162,7 +162,8 @@ class _MapContent extends StatelessWidget {
padding: const EdgeInsets.only(bottom: 70),
child: _PlaceCarousel(
places: state.recommendations,
onSelect: (place) => placeCubit.selectPlace(place.id),
onSelect: (place) =>
_selectAndShowPlace(context, place),
onFavoriteToggle: placeCubit.toggleFavorite,
),
)
@@ -226,6 +227,48 @@ class _MapContent extends StatelessWidget {
void _openAddFlow(BuildContext context, LatLng? coordinate) {
context.push(MapflowRoutes.addExperienceLocation(coordinate: coordinate));
}
void _selectAndShowPlace(BuildContext context, PlaceRecommendation place) {
final placeCubit = context.read<PlaceCubit>()..selectPlace(place.id);
unawaited(
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) {
return _PlaceDetailsSheet(
place: place,
onFavoriteToggle: () => placeCubit.toggleFavorite(place),
onPhotoTap: (index) =>
_openPhotoViewer(sheetContext, place.photoUrls, index),
);
},
),
);
}
void _openPhotoViewer(
BuildContext context,
List<String> photoUrls,
int initialIndex,
) {
if (photoUrls.isEmpty) {
return;
}
unawaited(
showDialog<void>(
context: context,
barrierColor: Colors.black,
builder: (_) {
return _PlacePhotoViewer(
photoUrls: photoUrls,
initialIndex: initialIndex,
);
},
),
);
}
}
Map<PlaceTrait, int> _countPlaceTraits(List<PlaceRecommendation> places) {
@@ -256,7 +299,7 @@ class _UserLocationMarker extends StatelessWidget {
decoration: BoxDecoration(
shape: BoxShape.circle,
color: color,
border: Border.all(color: Colors.white, width: 3),
border: Border.all(color: context.mapflowTokens.mapPanel, width: 3),
),
),
),
@@ -373,6 +416,7 @@ class _AddReviewAction extends StatelessWidget {
style: FilledButton.styleFrom(
backgroundColor: tokens.mapPanel,
foregroundColor: colorScheme.onSurface,
side: BorderSide(color: tokens.mapPanelBorder),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
),
),
@@ -394,7 +438,10 @@ class _ActiveRecommendationChip extends StatelessWidget {
padding: const EdgeInsets.only(top: 62),
child: Material(
color: tokens.mapPanel,
borderRadius: BorderRadius.circular(tokens.panelRadius),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(tokens.panelRadius),
side: BorderSide(color: tokens.mapPanelBorder),
),
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 6, 4, 6),
child: Row(
@@ -441,6 +488,10 @@ class _SearchLauncherButton extends StatelessWidget {
label: const Text('Поиск'),
backgroundColor: tokens.mapPanel,
foregroundColor: colorScheme.onSurface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(tokens.panelRadius),
side: BorderSide(color: tokens.mapPanelBorder),
),
),
);
}
@@ -884,9 +935,9 @@ class _TraitPickerSheet extends StatelessWidget {
label: Text('${item.label} ${traitCounts[item] ?? 0}'),
selected: item == selectedTrait,
onSelected: (_) => onSelect(item),
backgroundColor: tokens.mapPanel,
backgroundColor: tokens.darkSurfaceAlt,
selectedColor: Theme.of(context).colorScheme.primaryContainer,
side: BorderSide.none,
side: BorderSide(color: tokens.mapPanelBorder),
shape: const StadiumBorder(),
padding: const EdgeInsets.symmetric(
horizontal: 10,
@@ -915,7 +966,7 @@ class _PlaceMarker extends StatelessWidget {
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
decoration: BoxDecoration(
color: selected ? color : Colors.white,
color: selected ? color : tokens.mapPanel,
shape: BoxShape.circle,
border: Border.all(color: color, width: selected ? 3 : 2),
boxShadow: [
@@ -926,7 +977,7 @@ class _PlaceMarker extends StatelessWidget {
),
],
),
child: Icon(Icons.place, color: selected ? Colors.white : color),
child: Icon(Icons.place, color: selected ? tokens.onDark : color),
),
);
}
@@ -969,6 +1020,413 @@ class _PlaceCarousel extends StatelessWidget {
}
}
class _PlaceDetailsSheet extends StatefulWidget {
const _PlaceDetailsSheet({
required this.place,
required this.onFavoriteToggle,
required this.onPhotoTap,
});
final PlaceRecommendation place;
final VoidCallback onFavoriteToggle;
final ValueChanged<int> onPhotoTap;
@override
State<_PlaceDetailsSheet> createState() => _PlaceDetailsSheetState();
}
class _PlaceDetailsSheetState extends State<_PlaceDetailsSheet> {
late var _isFavorite = widget.place.isFavorite;
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
final colorScheme = Theme.of(context).colorScheme;
final openingLabel = _openingLabel(widget.place);
final weekdayDescriptions = _weekdayDescriptions(widget.place);
final typeLabel = _placeTypeLabel(widget.place);
return SafeArea(
top: false,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.86,
),
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 18),
children: [
_PlaceDetailsPhotos(
photoUrls: widget.place.photoUrls,
onPhotoTap: widget.onPhotoTap,
),
const SizedBox(height: 14),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.place.name,
style: Theme.of(context).textTheme.headlineSmall
?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: 0,
),
),
if (typeLabel != null) ...[
const SizedBox(height: 4),
Text(
typeLabel,
style: Theme.of(context).textTheme.bodyMedium
?.copyWith(color: colorScheme.onSurfaceVariant),
),
],
],
),
),
IconButton.filledTonal(
onPressed: _toggleFavorite,
icon: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
),
tooltip: 'Избранное',
),
],
),
const SizedBox(height: 12),
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 (widget.place.traits.isNotEmpty) ...[
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final trait in widget.place.traits)
Chip(
avatar: Icon(trait.icon, size: 16),
label: Text(trait.label),
side: BorderSide(color: tokens.mapPanelBorder),
backgroundColor: tokens.mapPanel,
),
],
),
],
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,
),
),
],
),
),
),
],
],
),
),
);
}
void _toggleFavorite() {
setState(() => _isFavorite = !_isFavorite);
widget.onFavoriteToggle();
}
}
class _PlaceDetailsPhotos extends StatelessWidget {
const _PlaceDetailsPhotos({
required this.photoUrls,
required this.onPhotoTap,
});
final List<String> photoUrls;
final ValueChanged<int> onPhotoTap;
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
final colorScheme = Theme.of(context).colorScheme;
if (photoUrls.isEmpty) {
return AspectRatio(
aspectRatio: 16 / 9,
child: DecoratedBox(
decoration: BoxDecoration(
color: tokens.mapPanelBorder,
borderRadius: BorderRadius.circular(tokens.panelRadius),
),
child: Icon(
Icons.place_outlined,
color: colorScheme.onSurfaceVariant,
size: 40,
),
),
);
}
return SizedBox(
height: 210,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: photoUrls.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (context, index) {
return GestureDetector(
onTap: () => onPhotoTap(index),
child: AspectRatio(
aspectRatio: 4 / 3,
child: ClipRRect(
borderRadius: BorderRadius.circular(tokens.panelRadius),
child: Image.network(
photoUrls[index],
fit: BoxFit.cover,
gaplessPlayback: true,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) {
return child;
}
return ColoredBox(
color: tokens.mapPanelBorder,
child: Center(
child: SizedBox.square(
dimension: 24,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.primary,
),
),
),
);
},
errorBuilder: (_, _, _) => ColoredBox(
color: tokens.mapPanelBorder,
child: Icon(
Icons.broken_image_outlined,
color: colorScheme.outline,
),
),
),
),
),
);
},
),
);
}
}
class _PlacePhotoViewer extends StatelessWidget {
const _PlacePhotoViewer({
required this.photoUrls,
required this.initialIndex,
});
final List<String> photoUrls;
final int initialIndex;
@override
Widget build(BuildContext context) {
final controller = PageController(initialPage: initialIndex);
return Dialog.fullscreen(
backgroundColor: Colors.black,
child: Stack(
children: [
PageView.builder(
controller: controller,
itemCount: photoUrls.length,
itemBuilder: (context, index) {
return InteractiveViewer(
minScale: 1,
maxScale: 4,
child: Center(
child: Image.network(
photoUrls[index],
fit: BoxFit.contain,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) {
return child;
}
return const CircularProgressIndicator();
},
errorBuilder: (_, _, _) => const Icon(
Icons.broken_image_outlined,
color: Colors.white,
size: 42,
),
),
),
);
},
),
SafeArea(
child: Align(
alignment: Alignment.topRight,
child: Padding(
padding: const EdgeInsets.all(8),
child: IconButton.filled(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
tooltip: 'Закрыть',
),
),
),
),
],
),
);
}
}
class _InfoChip extends StatelessWidget {
const _InfoChip({
required this.icon,
required this.label,
this.highlighted = false,
});
final IconData icon;
final String label;
final bool highlighted;
@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,
);
}
}
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) {
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;
}
List<String> _weekdayDescriptions(PlaceRecommendation place) {
final current = _stringList(
place.googleCurrentOpeningHours?['weekdayDescriptions'],
);
if (current.isNotEmpty) {
return current;
}
final regular = _stringList(
place.googleRegularOpeningHours?['weekdayDescriptions'],
);
if (regular.isNotEmpty) {
return regular;
}
final legacy = _stringList(place.googleRegularOpeningHours?['weekday_text']);
if (legacy.isNotEmpty) {
return legacy;
}
return const [];
}
List<String> _stringList(Object? value) {
if (value is! List) {
return const [];
}
return [
for (final item in value)
if (item is String && item.trim().isNotEmpty) item,
];
}
String? _placeTypeLabel(PlaceRecommendation place) {
final raw =
place.googlePrimaryType ??
(place.googleTypes.isEmpty ? null : place.googleTypes.first);
if (raw == null || raw.trim().isEmpty) {
return null;
}
return raw.replaceAll('_', ' ');
}
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});
@@ -1571,11 +2029,11 @@ class _VoiceStep extends StatelessWidget {
for (final hint in promptHints)
Chip(
label: Text(hint),
side: BorderSide.none,
backgroundColor: tokens.onDark.withValues(alpha: 0.12),
side: BorderSide(color: tokens.voiceAccent),
backgroundColor: tokens.voiceAccent,
labelStyle: TextStyle(
color: tokens.onDark.withValues(alpha: 0.92),
fontWeight: FontWeight.w700,
color: tokens.onVoiceControl,
fontWeight: FontWeight.w900,
),
),
],
@@ -18,7 +18,6 @@ class PlacePhotoCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final tokens = context.mapflowTokens;
final colorScheme = Theme.of(context).colorScheme;
final photoUrls = place.photoUrls.take(3).toList();
return GestureDetector(
@@ -27,7 +26,7 @@ class PlacePhotoCard extends StatelessWidget {
width: 226,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(tokens.panelRadius),
border: Border.all(color: colorScheme.surface),
border: Border.all(color: tokens.mapPanelBorder),
boxShadow: [
BoxShadow(
color: tokens.mapShadow,
@@ -42,8 +41,8 @@ class PlacePhotoCard extends StatelessWidget {
children: [
if (photoUrls.isEmpty)
ColoredBox(
color: colorScheme.primary,
child: Icon(Icons.place_outlined, color: colorScheme.onPrimary),
color: tokens.mapPanelBorder,
child: Icon(Icons.place_outlined, color: tokens.onDark),
)
else
_PhotoTriptych(
@@ -152,12 +151,32 @@ class _PhotoTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Image.network(
url,
fit: BoxFit.cover,
gaplessPlayback: true,
loadingBuilder: (context, child, loadingProgress) {
if (loadingProgress == null) {
return child;
}
return ColoredBox(
color: fallbackColor,
child: Center(
child: SizedBox.square(
dimension: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colorScheme.primary,
),
),
),
);
},
errorBuilder: (_, _, _) => ColoredBox(
color: fallbackColor,
child: const Icon(Icons.place_outlined),
child: Icon(Icons.broken_image_outlined, color: colorScheme.outline),
),
);
}