diff --git a/lib/app/app.dart b/lib/app/app.dart index 6998761..7ea15a3 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -84,7 +84,7 @@ class _MapflowAppState extends State { PointerDeviceKind.stylus, }, ), - theme: MapflowTheme.light(), + theme: MapflowTheme.dark(), builder: (context, child) { return Shortcuts( shortcuts: shortcuts, diff --git a/lib/app/theme/mapflow_theme.dart b/lib/app/theme/mapflow_theme.dart index 121f2cd..1280e0b 100644 --- a/lib/app/theme/mapflow_theme.dart +++ b/lib/app/theme/mapflow_theme.dart @@ -111,38 +111,38 @@ class MapflowTheme { const MapflowTheme._(); static const tokens = MapflowThemeTokens( - mapPanel: Color(0xFFFFFBF5), - mapPanelBorder: Color(0xFFE0D8CA), - mapShadow: Color(0x33000000), - danger: Color(0xFFE11D48), + mapPanel: Color(0xFF15111D), + mapPanelBorder: Color(0xFF2F263D), + mapShadow: Color(0x99000000), + danger: Color(0xFFFF2D75), onDark: Colors.white, darkSurface: Color(0xFF05030B), darkSurfaceAlt: Color(0xFF15111D), voiceAccent: Color(0xFFFF2D75), - voiceAccentSoft: Color(0xFFFF7A90), + voiceAccentSoft: Color(0xFFFF8BB5), onVoiceControl: Color(0xFF090613), - snowflakeLine: Color(0xFFE6DDD2), - snowflakeNode: Color(0xFFDED3C7), - snowflakeCenter: Color(0xFF241B18), - snowflakeLabel: Color(0xFF746A60), + snowflakeLine: Color(0xFF3B304B), + snowflakeNode: Color(0xFF67567E), + snowflakeCenter: Color(0xFFFF2D75), + snowflakeLabel: Color(0xFFCFC1DE), imageScrim: Color(0xCC000000), panelRadius: 8, ); - static ThemeData light() { - return FlexThemeData.light( + static ThemeData dark() { + return FlexThemeData.dark( useMaterial3: true, colors: const FlexSchemeColor( - primary: Color(0xFF0F766E), - primaryContainer: Color(0xFFBFE7DF), - secondary: Color(0xFFE11D48), - secondaryContainer: Color(0xFFFFD9E1), - tertiary: Color(0xFF7C3AED), - tertiaryContainer: Color(0xFFE9DDFF), - appBarColor: Color(0xFFF7F3EA), - error: Color(0xFFB3261E), + primary: Color(0xFFFF2D75), + primaryContainer: Color(0xFF5B1432), + secondary: Color(0xFFB86BFF), + secondaryContainer: Color(0xFF3E205A), + tertiary: Color(0xFFFFB86B), + tertiaryContainer: Color(0xFF4F2D13), + appBarColor: Color(0xFF05030B), + error: Color(0xFFFFB4AB), ), - scaffoldBackground: const Color(0xFFF7F3EA), + scaffoldBackground: tokens.darkSurface, surface: tokens.mapPanel, subThemesData: const FlexSubThemesData( defaultRadius: 8, @@ -155,10 +155,16 @@ class MapflowTheme { extensions: const [tokens], ).copyWith( appBarTheme: const AppBarTheme( - backgroundColor: Color(0xFFF7F3EA), - foregroundColor: Color(0xFF17211D), + backgroundColor: Color(0xFF05030B), + foregroundColor: Colors.white, surfaceTintColor: Colors.transparent, ), + bottomSheetTheme: const BottomSheetThemeData( + backgroundColor: Color(0xFF15111D), + modalBackgroundColor: Color(0xFF15111D), + surfaceTintColor: Colors.transparent, + dragHandleColor: Color(0xFF8F7AA7), + ), cardTheme: CardThemeData( elevation: 0, color: tokens.mapPanel, @@ -168,7 +174,25 @@ class MapflowTheme { 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( + 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)), ), ); diff --git a/lib/features/mapflow/presentation/admin_voice_experiences_screen.dart b/lib/features/mapflow/presentation/admin_voice_experiences_screen.dart index 6f043cf..85d984b 100644 --- a/lib/features/mapflow/presentation/admin_voice_experiences_screen.dart +++ b/lib/features/mapflow/presentation/admin_voice_experiences_screen.dart @@ -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>( 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( diff --git a/lib/features/mapflow/presentation/mapflow_shell.dart b/lib/features/mapflow/presentation/mapflow_shell.dart index c7368b2..e12f76f 100644 --- a/lib/features/mapflow/presentation/mapflow_shell.dart +++ b/lib/features/mapflow/presentation/mapflow_shell.dart @@ -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()..selectPlace(place.id); + unawaited( + showModalBottomSheet( + 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 photoUrls, + int initialIndex, + ) { + if (photoUrls.isEmpty) { + return; + } + + unawaited( + showDialog( + context: context, + barrierColor: Colors.black, + builder: (_) { + return _PlacePhotoViewer( + photoUrls: photoUrls, + initialIndex: initialIndex, + ); + }, + ), + ); + } } Map _countPlaceTraits(List 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 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 photoUrls; + final ValueChanged 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 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 _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 _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, ), ), ], diff --git a/lib/features/mapflow/presentation/widgets/place_photo_card.dart b/lib/features/mapflow/presentation/widgets/place_photo_card.dart index 8f80b70..4fd96b8 100644 --- a/lib/features/mapflow/presentation/widgets/place_photo_card.dart +++ b/lib/features/mapflow/presentation/widgets/place_photo_card.dart @@ -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), ), ); }