This commit is contained in:
+1
-1
@@ -84,7 +84,7 @@ class _MapflowAppState extends State<MapflowApp> {
|
|||||||
PointerDeviceKind.stylus,
|
PointerDeviceKind.stylus,
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
theme: MapflowTheme.light(),
|
theme: MapflowTheme.dark(),
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
return Shortcuts(
|
return Shortcuts(
|
||||||
shortcuts: shortcuts,
|
shortcuts: shortcuts,
|
||||||
|
|||||||
@@ -111,38 +111,38 @@ class MapflowTheme {
|
|||||||
const MapflowTheme._();
|
const MapflowTheme._();
|
||||||
|
|
||||||
static const tokens = MapflowThemeTokens(
|
static const tokens = MapflowThemeTokens(
|
||||||
mapPanel: Color(0xFFFFFBF5),
|
mapPanel: Color(0xFF15111D),
|
||||||
mapPanelBorder: Color(0xFFE0D8CA),
|
mapPanelBorder: Color(0xFF2F263D),
|
||||||
mapShadow: Color(0x33000000),
|
mapShadow: Color(0x99000000),
|
||||||
danger: Color(0xFFE11D48),
|
danger: Color(0xFFFF2D75),
|
||||||
onDark: Colors.white,
|
onDark: Colors.white,
|
||||||
darkSurface: Color(0xFF05030B),
|
darkSurface: Color(0xFF05030B),
|
||||||
darkSurfaceAlt: Color(0xFF15111D),
|
darkSurfaceAlt: Color(0xFF15111D),
|
||||||
voiceAccent: Color(0xFFFF2D75),
|
voiceAccent: Color(0xFFFF2D75),
|
||||||
voiceAccentSoft: Color(0xFFFF7A90),
|
voiceAccentSoft: Color(0xFFFF8BB5),
|
||||||
onVoiceControl: Color(0xFF090613),
|
onVoiceControl: Color(0xFF090613),
|
||||||
snowflakeLine: Color(0xFFE6DDD2),
|
snowflakeLine: Color(0xFF3B304B),
|
||||||
snowflakeNode: Color(0xFFDED3C7),
|
snowflakeNode: Color(0xFF67567E),
|
||||||
snowflakeCenter: Color(0xFF241B18),
|
snowflakeCenter: Color(0xFFFF2D75),
|
||||||
snowflakeLabel: Color(0xFF746A60),
|
snowflakeLabel: Color(0xFFCFC1DE),
|
||||||
imageScrim: Color(0xCC000000),
|
imageScrim: Color(0xCC000000),
|
||||||
panelRadius: 8,
|
panelRadius: 8,
|
||||||
);
|
);
|
||||||
|
|
||||||
static ThemeData light() {
|
static ThemeData dark() {
|
||||||
return FlexThemeData.light(
|
return FlexThemeData.dark(
|
||||||
useMaterial3: true,
|
useMaterial3: true,
|
||||||
colors: const FlexSchemeColor(
|
colors: const FlexSchemeColor(
|
||||||
primary: Color(0xFF0F766E),
|
primary: Color(0xFFFF2D75),
|
||||||
primaryContainer: Color(0xFFBFE7DF),
|
primaryContainer: Color(0xFF5B1432),
|
||||||
secondary: Color(0xFFE11D48),
|
secondary: Color(0xFFB86BFF),
|
||||||
secondaryContainer: Color(0xFFFFD9E1),
|
secondaryContainer: Color(0xFF3E205A),
|
||||||
tertiary: Color(0xFF7C3AED),
|
tertiary: Color(0xFFFFB86B),
|
||||||
tertiaryContainer: Color(0xFFE9DDFF),
|
tertiaryContainer: Color(0xFF4F2D13),
|
||||||
appBarColor: Color(0xFFF7F3EA),
|
appBarColor: Color(0xFF05030B),
|
||||||
error: Color(0xFFB3261E),
|
error: Color(0xFFFFB4AB),
|
||||||
),
|
),
|
||||||
scaffoldBackground: const Color(0xFFF7F3EA),
|
scaffoldBackground: tokens.darkSurface,
|
||||||
surface: tokens.mapPanel,
|
surface: tokens.mapPanel,
|
||||||
subThemesData: const FlexSubThemesData(
|
subThemesData: const FlexSubThemesData(
|
||||||
defaultRadius: 8,
|
defaultRadius: 8,
|
||||||
@@ -155,10 +155,16 @@ class MapflowTheme {
|
|||||||
extensions: const [tokens],
|
extensions: const [tokens],
|
||||||
).copyWith(
|
).copyWith(
|
||||||
appBarTheme: const AppBarTheme(
|
appBarTheme: const AppBarTheme(
|
||||||
backgroundColor: Color(0xFFF7F3EA),
|
backgroundColor: Color(0xFF05030B),
|
||||||
foregroundColor: Color(0xFF17211D),
|
foregroundColor: Colors.white,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
),
|
),
|
||||||
|
bottomSheetTheme: const BottomSheetThemeData(
|
||||||
|
backgroundColor: Color(0xFF15111D),
|
||||||
|
modalBackgroundColor: Color(0xFF15111D),
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
dragHandleColor: Color(0xFF8F7AA7),
|
||||||
|
),
|
||||||
cardTheme: CardThemeData(
|
cardTheme: CardThemeData(
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
color: tokens.mapPanel,
|
color: tokens.mapPanel,
|
||||||
@@ -168,7 +174,25 @@ class MapflowTheme {
|
|||||||
side: BorderSide(color: tokens.mapPanelBorder),
|
side: BorderSide(color: tokens.mapPanelBorder),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
dialogTheme: const DialogThemeData(
|
||||||
|
backgroundColor: Color(0xFF15111D),
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
),
|
||||||
|
popupMenuTheme: PopupMenuThemeData(
|
||||||
|
color: tokens.mapPanel,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(tokens.panelRadius),
|
||||||
|
side: BorderSide(color: tokens.mapPanelBorder),
|
||||||
|
),
|
||||||
|
),
|
||||||
chipTheme: ChipThemeData(
|
chipTheme: ChipThemeData(
|
||||||
|
backgroundColor: tokens.mapPanel,
|
||||||
|
selectedColor: const Color(0xFF5B1432),
|
||||||
|
disabledColor: const Color(0xFF211A2B),
|
||||||
|
secondarySelectedColor: const Color(0xFF5B1432),
|
||||||
|
labelStyle: const TextStyle(color: Color(0xFFF6ECFF)),
|
||||||
|
secondaryLabelStyle: const TextStyle(color: Colors.white),
|
||||||
shape: StadiumBorder(side: BorderSide(color: tokens.mapPanelBorder)),
|
shape: StadiumBorder(side: BorderSide(color: tokens.mapPanelBorder)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class _AdminVoiceExperiencesScreenState
|
|||||||
final tokens = context.mapflowTokens;
|
final tokens = context.mapflowTokens;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: tokens.mapPanel,
|
backgroundColor: tokens.darkSurface,
|
||||||
appBar: AppBar(title: const Text('Отзывы')),
|
appBar: AppBar(title: const Text('Отзывы')),
|
||||||
body: FutureBuilder<List<VoiceExperienceDebug>>(
|
body: FutureBuilder<List<VoiceExperienceDebug>>(
|
||||||
future: _future,
|
future: _future,
|
||||||
@@ -64,8 +64,11 @@ class _AdminVoiceExperienceRow extends StatelessWidget {
|
|||||||
final tokens = context.mapflowTokens;
|
final tokens = context.mapflowTokens;
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: tokens.mapPanel,
|
||||||
borderRadius: BorderRadius.circular(tokens.panelRadius),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(tokens.panelRadius),
|
||||||
|
side: BorderSide(color: tokens.mapPanelBorder),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ class _MapContent extends StatelessWidget {
|
|||||||
point: place.coordinate,
|
point: place.coordinate,
|
||||||
child: _PlaceMarker(
|
child: _PlaceMarker(
|
||||||
selected: selected?.id == place.id,
|
selected: selected?.id == place.id,
|
||||||
onTap: () => placeCubit.selectPlace(place.id),
|
onTap: () => _selectAndShowPlace(context, place),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (userCoordinate != null)
|
if (userCoordinate != null)
|
||||||
@@ -162,7 +162,8 @@ class _MapContent extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.only(bottom: 70),
|
padding: const EdgeInsets.only(bottom: 70),
|
||||||
child: _PlaceCarousel(
|
child: _PlaceCarousel(
|
||||||
places: state.recommendations,
|
places: state.recommendations,
|
||||||
onSelect: (place) => placeCubit.selectPlace(place.id),
|
onSelect: (place) =>
|
||||||
|
_selectAndShowPlace(context, place),
|
||||||
onFavoriteToggle: placeCubit.toggleFavorite,
|
onFavoriteToggle: placeCubit.toggleFavorite,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -226,6 +227,48 @@ class _MapContent extends StatelessWidget {
|
|||||||
void _openAddFlow(BuildContext context, LatLng? coordinate) {
|
void _openAddFlow(BuildContext context, LatLng? coordinate) {
|
||||||
context.push(MapflowRoutes.addExperienceLocation(coordinate: 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) {
|
Map<PlaceTrait, int> _countPlaceTraits(List<PlaceRecommendation> places) {
|
||||||
@@ -256,7 +299,7 @@ class _UserLocationMarker extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: color,
|
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(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: tokens.mapPanel,
|
backgroundColor: tokens.mapPanel,
|
||||||
foregroundColor: colorScheme.onSurface,
|
foregroundColor: colorScheme.onSurface,
|
||||||
|
side: BorderSide(color: tokens.mapPanelBorder),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -394,7 +438,10 @@ class _ActiveRecommendationChip extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.only(top: 62),
|
padding: const EdgeInsets.only(top: 62),
|
||||||
child: Material(
|
child: Material(
|
||||||
color: tokens.mapPanel,
|
color: tokens.mapPanel,
|
||||||
borderRadius: BorderRadius.circular(tokens.panelRadius),
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(tokens.panelRadius),
|
||||||
|
side: BorderSide(color: tokens.mapPanelBorder),
|
||||||
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 6, 4, 6),
|
padding: const EdgeInsets.fromLTRB(12, 6, 4, 6),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -441,6 +488,10 @@ class _SearchLauncherButton extends StatelessWidget {
|
|||||||
label: const Text('Поиск'),
|
label: const Text('Поиск'),
|
||||||
backgroundColor: tokens.mapPanel,
|
backgroundColor: tokens.mapPanel,
|
||||||
foregroundColor: colorScheme.onSurface,
|
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}'),
|
label: Text('${item.label} ${traitCounts[item] ?? 0}'),
|
||||||
selected: item == selectedTrait,
|
selected: item == selectedTrait,
|
||||||
onSelected: (_) => onSelect(item),
|
onSelected: (_) => onSelect(item),
|
||||||
backgroundColor: tokens.mapPanel,
|
backgroundColor: tokens.darkSurfaceAlt,
|
||||||
selectedColor: Theme.of(context).colorScheme.primaryContainer,
|
selectedColor: Theme.of(context).colorScheme.primaryContainer,
|
||||||
side: BorderSide.none,
|
side: BorderSide(color: tokens.mapPanelBorder),
|
||||||
shape: const StadiumBorder(),
|
shape: const StadiumBorder(),
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 10,
|
horizontal: 10,
|
||||||
@@ -915,7 +966,7 @@ class _PlaceMarker extends StatelessWidget {
|
|||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 160),
|
duration: const Duration(milliseconds: 160),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: selected ? color : Colors.white,
|
color: selected ? color : tokens.mapPanel,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(color: color, width: selected ? 3 : 2),
|
border: Border.all(color: color, width: selected ? 3 : 2),
|
||||||
boxShadow: [
|
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 {
|
class AddExperienceFlow extends StatefulWidget {
|
||||||
const AddExperienceFlow({super.key, required this.coordinate});
|
const AddExperienceFlow({super.key, required this.coordinate});
|
||||||
|
|
||||||
@@ -1571,11 +2029,11 @@ class _VoiceStep extends StatelessWidget {
|
|||||||
for (final hint in promptHints)
|
for (final hint in promptHints)
|
||||||
Chip(
|
Chip(
|
||||||
label: Text(hint),
|
label: Text(hint),
|
||||||
side: BorderSide.none,
|
side: BorderSide(color: tokens.voiceAccent),
|
||||||
backgroundColor: tokens.onDark.withValues(alpha: 0.12),
|
backgroundColor: tokens.voiceAccent,
|
||||||
labelStyle: TextStyle(
|
labelStyle: TextStyle(
|
||||||
color: tokens.onDark.withValues(alpha: 0.92),
|
color: tokens.onVoiceControl,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w900,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ class PlacePhotoCard extends StatelessWidget {
|
|||||||
@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 photoUrls = place.photoUrls.take(3).toList();
|
final photoUrls = place.photoUrls.take(3).toList();
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
@@ -27,7 +26,7 @@ class PlacePhotoCard extends StatelessWidget {
|
|||||||
width: 226,
|
width: 226,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(tokens.panelRadius),
|
borderRadius: BorderRadius.circular(tokens.panelRadius),
|
||||||
border: Border.all(color: colorScheme.surface),
|
border: Border.all(color: tokens.mapPanelBorder),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: tokens.mapShadow,
|
color: tokens.mapShadow,
|
||||||
@@ -42,8 +41,8 @@ class PlacePhotoCard extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
if (photoUrls.isEmpty)
|
if (photoUrls.isEmpty)
|
||||||
ColoredBox(
|
ColoredBox(
|
||||||
color: colorScheme.primary,
|
color: tokens.mapPanelBorder,
|
||||||
child: Icon(Icons.place_outlined, color: colorScheme.onPrimary),
|
child: Icon(Icons.place_outlined, color: tokens.onDark),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
_PhotoTriptych(
|
_PhotoTriptych(
|
||||||
@@ -152,12 +151,32 @@ class _PhotoTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
return Image.network(
|
return Image.network(
|
||||||
url,
|
url,
|
||||||
fit: BoxFit.cover,
|
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(
|
errorBuilder: (_, _, _) => ColoredBox(
|
||||||
color: fallbackColor,
|
color: fallbackColor,
|
||||||
child: const Icon(Icons.place_outlined),
|
child: Icon(Icons.broken_image_outlined, color: colorScheme.outline),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user