diff --git a/app/lib/app/router.dart b/app/lib/app/router.dart index 2ca87e59..eecfd111 100644 --- a/app/lib/app/router.dart +++ b/app/lib/app/router.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../core/identity/identity_provider.dart'; +import '../features/deck/deck_page.dart'; import '../features/home/home_page.dart'; import '../features/invite/invite_page.dart'; import '../features/onboarding/onboarding_intro_page.dart'; @@ -107,6 +108,15 @@ final routerProvider = Provider((ref) { return SessionPage(sessionId: sessionId); }, ), + + GoRoute( + path: '/sessions/:id/deck', + name: 'session-deck', + builder: (context, state) { + final sessionId = state.pathParameters['id'] ?? ''; + return DeckPage(sessionId: sessionId); + }, + ), GoRoute( path: '/sessions/:id/settings', name: 'session-settings', diff --git a/app/lib/core/format/formatters.dart b/app/lib/core/format/formatters.dart new file mode 100644 index 00000000..63e821f7 --- /dev/null +++ b/app/lib/core/format/formatters.dart @@ -0,0 +1,29 @@ +String formatDistance(double? meters) { + if (meters == null) { + return 'Unknown distance'; + } + + if (meters < 1000) { + return '${meters.round()} m'; + } + + final km = meters / 1000; + return '${km.toStringAsFixed(km >= 10 ? 0 : 1)} km'; +} + +String formatPriceLevel(int? level) { + if (level == null || level <= 0) { + return 'Any'; + } + + return r'$' * level; +} + +String formatRating(double? rating, int? reviewCount) { + if (rating == null) { + return 'No rating'; + } + + final reviews = reviewCount ?? 0; + return '${rating.toStringAsFixed(1)} ($reviews)'; +} diff --git a/app/lib/features/deck/deck_controller.dart b/app/lib/features/deck/deck_controller.dart new file mode 100644 index 00000000..5467abb6 --- /dev/null +++ b/app/lib/features/deck/deck_controller.dart @@ -0,0 +1,271 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/location/location_controller.dart'; +import '../../models/plan.dart'; +import '../../models/telemetry.dart'; +import '../../repositories/deck_repository.dart'; +import '../../repositories/sessions_repository.dart'; +import '../../repositories/telemetry_repository.dart'; +import 'deck_state.dart'; + +class DeckController extends StateNotifier { + DeckController({ + required String sessionId, + required SessionsRepository sessionsRepository, + required Future Function() deckRepository, + required Future Function() telemetryRepository, + required LocationControllerState Function() getLocationState, + required Future Function() requestPermissionAndLoadLocation, + }) : _sessionsRepository = sessionsRepository, + _deckRepository = deckRepository, + _telemetryRepository = telemetryRepository, + _getLocationState = getLocationState, + _requestPermissionAndLoadLocation = requestPermissionAndLoadLocation, + super(DeckState.initial(sessionId)); + + final SessionsRepository _sessionsRepository; + final Future Function() _deckRepository; + final Future Function() _telemetryRepository; + final LocationControllerState Function() _getLocationState; + final Future Function() _requestPermissionAndLoadLocation; + + DateTime? _viewStartedAt; + + Future initialize() async { + await _ensureLocation(); + if (!_hasEffectiveLocation) { + state = state.copyWith( + isLoadingInitial: false, + errorMessage: 'Location required', + ); + return; + } + await refresh(); + } + + Future requestLocation() async { + await _requestPermissionAndLoadLocation(); + await initialize(); + } + + Future refresh() async { + state = state.copyWith( + isLoadingInitial: true, + plans: const [], + nextCursor: null, + errorMessage: null, + ); + await _fetchBatch(forceRefresh: true); + } + + Future loadNextBatch() async { + if (state.isLoadingMore || !state.hasMore) { + return; + } + + state = state.copyWith(isLoadingMore: true, errorMessage: null); + await _fetchBatch(cursor: state.nextCursor); + } + + void onSwipeNo() => _swipe('no'); + void onSwipeYes() => _swipe('yes'); + void onSwipeMaybe() => _swipe('maybe'); + + void undo() { + if (state.undoStack.isEmpty) { + return; + } + + final restore = state.undoStack.last; + state = state.copyWith( + plans: [restore.plan, ...state.plans], + undoStack: [...state.undoStack]..removeLast(), + ); + _viewStartedAt = DateTime.now(); + } + + void onCardOpened(Plan plan) { + _emitCardViewed(plan); + _enqueueTelemetry( + TelemetryEventInput.cardOpened( + planId: plan.id, + source: plan.source, + ), + ); + _viewStartedAt = DateTime.now(); + } + + void onOutboundLinkClicked(Plan plan, String linkType) { + _enqueueTelemetry( + TelemetryEventInput.outboundLinkClicked( + planId: plan.id, + linkType: linkType, + source: plan.source, + ), + ); + } + + void registerTopCardViewed() { + _viewStartedAt ??= DateTime.now(); + } + + Future _ensureLocation() async { + if (_getLocationState().effectiveLocation != null) { + return; + } + + await _requestPermissionAndLoadLocation(); + } + + bool get _hasEffectiveLocation => _getLocationState().effectiveLocation != null; + + Future _fetchBatch({String? cursor, bool forceRefresh = false}) async { + final location = _getLocationState().effectiveLocation; + if (location == null) { + state = state.copyWith( + isLoadingInitial: false, + isLoadingMore: false, + errorMessage: 'Location required', + ); + return; + } + + try { + final session = await _sessionsRepository.getById(state.sessionId); + if (session == null) { + state = state.copyWith( + isLoadingInitial: false, + isLoadingMore: false, + errorMessage: 'Session not found', + ); + return; + } + + final deckRepository = await _deckRepository(); + final response = await deckRepository.fetchDeckBatch( + state.sessionId, + DeckQueryParams( + cursor: cursor, + lat: location.lat, + lng: location.lng, + radiusMeters: session.filters.radiusMeters, + categories: session.filters.categories.map((e) => e.name).toList(), + openNow: session.filters.openNow, + priceLevelMax: session.filters.priceLevelMax, + timeStart: session.filters.timeWindow?.startISO, + timeEnd: session.filters.timeWindow?.endISO, + ), + forceRefresh: forceRefresh, + ); + + final mergedPlans = _mergeUnique(state.plans, response.plans); + final fallbackCount = response.mix.planSourceCounts.entries + .where((entry) => entry.key == 'curated' || entry.key == 'byo') + .fold(0, (sum, entry) => sum + entry.value); + + state = state.copyWith( + isLoadingInitial: false, + isLoadingMore: false, + errorMessage: null, + plans: mergedPlans, + nextCursor: response.nextCursor, + hasMore: response.nextCursor != null, + lastBatchMix: response.mix, + usedFallback: response.plans.isEmpty || fallbackCount >= (response.plans.length ~/ 2), + ); + + _enqueueTelemetry( + TelemetryEventInput.deckLoaded( + batchSize: response.plans.length, + returned: response.plans.length, + nextCursorPresent: response.nextCursor != null, + cursor: cursor, + planSourceCounts: response.mix.planSourceCounts, + ), + ); + + if (state.plans.isNotEmpty) { + _viewStartedAt = DateTime.now(); + } + } catch (_) { + state = state.copyWith( + isLoadingInitial: false, + isLoadingMore: false, + errorMessage: 'Failed to load deck. Pull to retry.', + ); + } + } + + List _mergeUnique(List current, List incoming) { + final seenIds = current.map((plan) => plan.id).toSet(); + final merged = [...current]; + + for (final plan in incoming) { + if (!seenIds.contains(plan.id)) { + seenIds.add(plan.id); + merged.add(plan); + } + } + + return merged; + } + + void _enqueueTelemetry(TelemetryEventInput event) { + _telemetryRepository().then((repo) async { + repo.enqueue(state.sessionId, event); + await repo.flush(state.sessionId); + }); + } + + void _swipe(String action) { + if (state.plans.isEmpty) { + return; + } + + final topPlan = state.plans.first; + _emitCardViewed(topPlan); + + _enqueueTelemetry( + TelemetryEventInput.swipe( + planId: topPlan.id, + action: action, + source: topPlan.source, + position: state.shownPlanIds.length, + ), + ); + + final shownPlanIds = [...state.shownPlanIds]; + if (!shownPlanIds.contains(topPlan.id)) { + shownPlanIds.add(topPlan.id); + } + + state = state.copyWith( + plans: [...state.plans]..removeAt(0), + shownPlanIds: shownPlanIds, + undoStack: [ + ...state.undoStack, + SwipeRecord(plan: topPlan, action: action), + ], + ); + + _viewStartedAt = DateTime.now(); + if (state.plans.length <= 5 && state.hasMore) { + loadNextBatch(); + } + } + + void _emitCardViewed(Plan topPlan) { + final startedAt = _viewStartedAt; + if (startedAt == null) { + return; + } + + _enqueueTelemetry( + TelemetryEventInput.cardViewed( + planId: topPlan.id, + viewMs: DateTime.now().difference(startedAt).inMilliseconds, + source: topPlan.source, + ), + ); + } +} diff --git a/app/lib/features/deck/deck_page.dart b/app/lib/features/deck/deck_page.dart new file mode 100644 index 00000000..007ad76f --- /dev/null +++ b/app/lib/features/deck/deck_page.dart @@ -0,0 +1,181 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_card_swiper/flutter_card_swiper.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../../app/theme/spacing.dart'; +import '../../core/env/env.dart'; +import '../../providers/app_providers.dart'; +import 'deck_controller.dart'; +import 'deck_state.dart'; +import 'widgets/card_details_sheet.dart'; +import 'widgets/deck_actions_bar.dart'; +import 'widgets/deck_card.dart'; +import 'widgets/deck_card_skeleton.dart'; + +class DeckPage extends ConsumerStatefulWidget { + const DeckPage({required this.sessionId, super.key}); + + final String sessionId; + + @override + ConsumerState createState() => _DeckPageState(); +} + +class _DeckPageState extends ConsumerState { + final CardSwiperController _swiperController = CardSwiperController(); + + @override + void initState() { + super.initState(); + Future.microtask(() { + ref.read(deckControllerProvider(widget.sessionId).notifier).initialize(); + }); + } + + @override + void dispose() { + _swiperController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final deckState = ref.watch(deckControllerProvider(widget.sessionId)); + final deckController = ref.read(deckControllerProvider(widget.sessionId).notifier); + final env = ref.watch(envConfigProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Deck')), + body: Padding( + padding: const EdgeInsets.all(AppSpacing.m), + child: Column( + children: [ + Expanded(child: _buildContent(context, deckState, deckController, env)), + const SizedBox(height: AppSpacing.s), + DeckActionsBar( + isDisabled: deckState.plans.isEmpty || deckState.isLoadingInitial, + canUndo: deckState.undoStack.isNotEmpty, + onNo: () => _swiperController.swipe(CardSwiperDirection.left), + onYes: () => _swiperController.swipe(CardSwiperDirection.right), + onMaybe: () => _swiperController.swipe(CardSwiperDirection.top), + onUndo: deckController.undo, + onSuperYes: () { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Super Yes coming soon')), + ); + }, + ), + ], + ), + ), + ); + } + + Widget _buildContent( + BuildContext context, + DeckState state, + DeckController controller, + EnvConfig env, + ) { + if (state.isLoadingInitial && state.plans.isEmpty) { + return const DeckCardSkeleton(); + } + + if (state.errorMessage != null && state.plans.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(state.errorMessage!), + const SizedBox(height: AppSpacing.s), + FilledButton( + onPressed: controller.refresh, + child: const Text('Retry'), + ), + const SizedBox(height: AppSpacing.s), + OutlinedButton( + onPressed: controller.requestLocation, + child: const Text('Request location'), + ), + ], + ), + ); + } + + if (state.plans.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('No more ideas'), + const SizedBox(height: AppSpacing.s), + FilledButton(onPressed: controller.refresh, child: const Text('Retry')), + const SizedBox(height: AppSpacing.s), + OutlinedButton( + onPressed: () => context.push('/sessions/${widget.sessionId}/settings'), + child: const Text('Adjust filters'), + ), + ], + ), + ); + } + + controller.registerTopCardViewed(); + + return Column( + children: [ + Expanded( + child: CardSwiper( + controller: _swiperController, + cardsCount: state.plans.length, + isLoop: false, + allowedSwipeDirection: + const AllowedSwipeDirection.only(left: true, right: true, up: true), + onSwipe: (previousIndex, _, direction) { + if (direction == CardSwiperDirection.left) { + controller.onSwipeNo(); + } else if (direction == CardSwiperDirection.right) { + controller.onSwipeYes(); + } else if (direction == CardSwiperDirection.top) { + controller.onSwipeMaybe(); + } + return true; + }, + cardBuilder: (context, index, _, __) { + final plan = state.plans[index]; + return DeckCard( + plan: plan, + onTap: () async { + controller.onCardOpened(plan); + await CardDetailsSheet.show( + context, + plan: plan, + onLinkTap: (linkType) { + controller.onOutboundLinkClicked(plan, linkType); + }, + ); + }, + ); + }, + ), + ), + if (state.isLoadingMore) const LinearProgressIndicator(), + if (state.usedFallback) + const Padding( + padding: EdgeInsets.only(top: AppSpacing.s), + child: Text('Fallback suggestions are active.'), + ), + if (kDebugMode && env.enableDebugLogs && state.lastBatchMix != null) + Padding( + padding: const EdgeInsets.only(top: AppSpacing.s), + child: Text( + 'Sources: ${state.lastBatchMix!.planSourceCounts.entries.map((e) => '${e.key} ${e.value}').join(', ')}', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ); + } +} diff --git a/app/lib/features/deck/deck_state.dart b/app/lib/features/deck/deck_state.dart new file mode 100644 index 00000000..ab05d124 --- /dev/null +++ b/app/lib/features/deck/deck_state.dart @@ -0,0 +1,86 @@ +import '../../models/deck_batch.dart'; +import '../../models/plan.dart'; + +const _unset = Object(); + +class SwipeRecord { + const SwipeRecord({ + required this.plan, + required this.action, + }); + + final Plan plan; + final String action; +} + +class DeckState { + const DeckState({ + required this.sessionId, + required this.isLoadingInitial, + required this.isLoadingMore, + this.errorMessage, + required this.plans, + this.nextCursor, + required this.hasMore, + this.lastBatchMix, + required this.shownPlanIds, + required this.undoStack, + required this.usedFallback, + }); + + factory DeckState.initial(String sessionId) { + return DeckState( + sessionId: sessionId, + isLoadingInitial: true, + isLoadingMore: false, + plans: const [], + hasMore: true, + shownPlanIds: const [], + undoStack: const [], + usedFallback: false, + ); + } + + final String sessionId; + final bool isLoadingInitial; + final bool isLoadingMore; + final String? errorMessage; + final List plans; + final String? nextCursor; + final bool hasMore; + final DeckSourceMix? lastBatchMix; + final List shownPlanIds; + final List undoStack; + final bool usedFallback; + + DeckState copyWith({ + String? sessionId, + bool? isLoadingInitial, + bool? isLoadingMore, + Object? errorMessage = _unset, + List? plans, + Object? nextCursor = _unset, + bool? hasMore, + Object? lastBatchMix = _unset, + List? shownPlanIds, + List? undoStack, + bool? usedFallback, + }) { + return DeckState( + sessionId: sessionId ?? this.sessionId, + isLoadingInitial: isLoadingInitial ?? this.isLoadingInitial, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, + errorMessage: + identical(errorMessage, _unset) ? this.errorMessage : errorMessage as String?, + plans: plans ?? this.plans, + nextCursor: identical(nextCursor, _unset) ? this.nextCursor : nextCursor as String?, + hasMore: hasMore ?? this.hasMore, + lastBatchMix: identical(lastBatchMix, _unset) + ? this.lastBatchMix + : lastBatchMix as DeckSourceMix?, + shownPlanIds: shownPlanIds ?? this.shownPlanIds, + undoStack: undoStack ?? this.undoStack, + usedFallback: usedFallback ?? this.usedFallback, + ); + } +} diff --git a/app/lib/features/deck/widgets/card_details_sheet.dart b/app/lib/features/deck/widgets/card_details_sheet.dart new file mode 100644 index 00000000..905b1cb5 --- /dev/null +++ b/app/lib/features/deck/widgets/card_details_sheet.dart @@ -0,0 +1,253 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../../app/theme/spacing.dart'; +import '../../../core/links/link_types.dart'; +import '../../../models/plan.dart'; +import '../../../providers/app_providers.dart'; + +typedef LinkTapCallback = void Function(String linkType); + +class CardDetailsSheet extends ConsumerWidget { + const CardDetailsSheet({ + required this.plan, + required this.onLinkTap, + super.key, + }); + + final Plan plan; + final LinkTapCallback onLinkTap; + + static Future show( + BuildContext context, { + required Plan plan, + required LinkTapCallback onLinkTap, + }) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (context) => CardDetailsSheet(plan: plan, onLinkTap: onLinkTap), + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.m), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(plan.title, style: Theme.of(context).textTheme.headlineSmall), + const SizedBox(height: AppSpacing.xs), + Text(plan.category), + const SizedBox(height: AppSpacing.m), + _PhotosCarousel(photos: plan.photos ?? const []), + if (plan.description != null) ...[ + const SizedBox(height: AppSpacing.m), + Text(plan.description!), + ], + if (plan.location.address != null) ...[ + const SizedBox(height: AppSpacing.s), + Text(plan.location.address!), + ], + const SizedBox(height: AppSpacing.m), + Wrap( + spacing: AppSpacing.s, + runSpacing: AppSpacing.s, + children: [ + _LinkButton( + text: 'Maps', + link: plan.deepLinks?.mapsLink, + type: LinkType.maps, + planTitle: plan.title, + onLinkTap: onLinkTap, + ), + _LinkButton( + text: 'Website', + link: plan.deepLinks?.websiteLink, + type: LinkType.website, + planTitle: plan.title, + onLinkTap: onLinkTap, + ), + _LinkButton( + text: 'Call', + link: plan.deepLinks?.callLink, + type: LinkType.call, + planTitle: plan.title, + onLinkTap: onLinkTap, + ), + _LinkButton( + text: 'Booking', + link: plan.deepLinks?.bookingLink, + type: LinkType.booking, + planTitle: plan.title, + onLinkTap: onLinkTap, + ), + _LinkButton( + text: 'Tickets', + link: plan.deepLinks?.ticketLink, + type: LinkType.ticket, + planTitle: plan.title, + onLinkTap: onLinkTap, + ), + ], + ), + const SizedBox(height: AppSpacing.m), + _SpecialsList( + metadata: plan.metadata, + planTitle: plan.title, + onLinkTap: onLinkTap, + ), + ], + ), + ), + ), + ); + } +} + +class _PhotosCarousel extends StatelessWidget { + const _PhotosCarousel({required this.photos}); + + final List photos; + + @override + Widget build(BuildContext context) { + if (photos.isEmpty) { + return Container( + height: 180, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(12), + ), + alignment: Alignment.center, + child: const Icon(Icons.photo_library_outlined), + ); + } + + return SizedBox( + height: 220, + child: PageView.builder( + itemCount: photos.length, + itemBuilder: (context, index) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ClipRRect( + borderRadius: BorderRadius.circular(12), + child: CachedNetworkImage( + imageUrl: photos[index].url, + fit: BoxFit.cover, + placeholder: (context, _) => Container(color: Colors.grey.shade300), + errorWidget: (context, _, __) => Container( + color: Colors.grey.shade300, + child: const Icon(Icons.broken_image_outlined), + ), + ), + ), + ); + }, + ), + ); + } +} + +class _LinkButton extends ConsumerWidget { + const _LinkButton({ + required this.text, + required this.link, + required this.type, + required this.planTitle, + required this.onLinkTap, + }); + + final String text; + final String? link; + final LinkType type; + final String planTitle; + final LinkTapCallback onLinkTap; + + @override + Widget build(BuildContext context, WidgetRef ref) { + if (link == null) { + return const SizedBox.shrink(); + } + + return FilledButton.tonal( + onPressed: () async { + onLinkTap(type.name); + await ref.read(linkLauncherProvider).openLink( + context, + uri: Uri.parse(link!), + type: type, + planTitle: planTitle, + ); + }, + child: Text(text), + ); + } +} + +class _SpecialsList extends ConsumerWidget { + const _SpecialsList({ + required this.metadata, + required this.planTitle, + required this.onLinkTap, + }); + + final Map? metadata; + final String planTitle; + final LinkTapCallback onLinkTap; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final specials = (metadata?['specials'] as List? ?? const []) + .whereType>() + .take(2) + .map((entry) => entry.map((key, value) => MapEntry('$key', value))) + .toList(); + + if (specials.isEmpty) { + return const SizedBox.shrink(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Specials', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: AppSpacing.s), + ...specials.map( + (special) => Card( + child: ListTile( + title: Text((special['headline'] as String?) ?? 'Offer'), + subtitle: Text( + [ + (special['details'] as String?) ?? '', + if ((special['couponCode'] as String?) != null) + 'Code: ${special['couponCode']}', + ].where((value) => value.isNotEmpty).join('\n'), + ), + trailing: (special['bookingLink'] as String?) == null + ? null + : TextButton( + onPressed: () async { + final link = special['bookingLink'] as String; + onLinkTap(LinkType.booking.name); + await ref.read(linkLauncherProvider).openLink( + context, + uri: Uri.parse(link), + type: LinkType.booking, + planTitle: planTitle, + ); + }, + child: const Text('Book'), + ), + ), + ), + ), + ], + ); + } +} diff --git a/app/lib/features/deck/widgets/category_pill.dart b/app/lib/features/deck/widgets/category_pill.dart new file mode 100644 index 00000000..971e8bbb --- /dev/null +++ b/app/lib/features/deck/widgets/category_pill.dart @@ -0,0 +1,15 @@ +import 'package:flutter/material.dart'; + +class CategoryPill extends StatelessWidget { + const CategoryPill({required this.category, super.key}); + + final String category; + + @override + Widget build(BuildContext context) { + return Chip( + visualDensity: VisualDensity.compact, + label: Text(category), + ); + } +} diff --git a/app/lib/features/deck/widgets/deck_actions_bar.dart b/app/lib/features/deck/widgets/deck_actions_bar.dart new file mode 100644 index 00000000..622bb60b --- /dev/null +++ b/app/lib/features/deck/widgets/deck_actions_bar.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; + +import '../../../app/theme/spacing.dart'; + +class DeckActionsBar extends StatelessWidget { + const DeckActionsBar({ + required this.isDisabled, + required this.canUndo, + required this.onNo, + required this.onYes, + required this.onMaybe, + required this.onUndo, + required this.onSuperYes, + super.key, + }); + + final bool isDisabled; + final bool canUndo; + final VoidCallback onNo; + final VoidCallback onYes; + final VoidCallback onMaybe; + final VoidCallback onUndo; + final VoidCallback onSuperYes; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: OutlinedButton.icon( + onPressed: isDisabled ? null : onNo, + icon: const Icon(Icons.close), + label: const Text('No'), + ), + ), + const SizedBox(width: AppSpacing.s), + Expanded( + child: OutlinedButton.icon( + onPressed: isDisabled ? null : onMaybe, + icon: const Icon(Icons.north), + label: const Text('Maybe'), + ), + ), + const SizedBox(width: AppSpacing.s), + Expanded( + child: FilledButton.icon( + onPressed: isDisabled ? null : onYes, + icon: const Icon(Icons.favorite), + label: const Text('Yes'), + ), + ), + const SizedBox(width: AppSpacing.s), + IconButton( + onPressed: canUndo ? onUndo : null, + icon: const Icon(Icons.undo), + ), + IconButton( + onPressed: isDisabled ? null : onSuperYes, + icon: const Icon(Icons.bolt), + ), + ], + ); + } +} diff --git a/app/lib/features/deck/widgets/deck_card.dart b/app/lib/features/deck/widgets/deck_card.dart new file mode 100644 index 00000000..1bfb137f --- /dev/null +++ b/app/lib/features/deck/widgets/deck_card.dart @@ -0,0 +1,112 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; + +import '../../../app/theme/spacing.dart'; +import '../../../core/format/formatters.dart'; +import '../../../models/plan.dart'; +import 'category_pill.dart'; +import 'price_pill.dart'; +import 'rating_row.dart'; +import 'specials_badge.dart'; +import 'sponsored_badge.dart'; + +class DeckCard extends StatelessWidget { + const DeckCard({ + required this.plan, + required this.onTap, + super.key, + }); + + final Plan plan; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final hasSpecials = (plan.metadata?['specials'] as List?)?.isNotEmpty ?? false; + final isSponsored = + plan.metadata?['sponsored'] == true || plan.source.toLowerCase() == 'promoted'; + + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: _PhotoHeader(plan: plan), + ), + Padding( + padding: const EdgeInsets.all(AppSpacing.m), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + plan.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: AppSpacing.s), + Wrap( + spacing: AppSpacing.xs, + runSpacing: AppSpacing.xs, + children: [ + CategoryPill(category: plan.category), + PricePill(priceLevel: plan.priceLevel), + Chip(label: Text(formatDistance(plan.distanceMeters))), + if (isSponsored) const SponsoredBadge(), + if (hasSpecials) const SpecialsBadge(), + ], + ), + const SizedBox(height: AppSpacing.s), + RatingRow(rating: plan.rating, reviewCount: plan.reviewCount), + const SizedBox(height: AppSpacing.xs), + Text( + plan.hours?.openNow == null + ? 'Hours unavailable' + : plan.hours!.openNow! + ? 'Open now' + : 'Closed', + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _PhotoHeader extends StatelessWidget { + const _PhotoHeader({required this.plan}); + + final Plan plan; + + @override + Widget build(BuildContext context) { + final photoUrl = plan.photos?.isNotEmpty == true ? plan.photos!.first.url : null; + if (photoUrl == null) { + return Container( + color: Colors.grey.shade300, + alignment: Alignment.center, + child: const Icon(Icons.image_not_supported_outlined), + ); + } + + return CachedNetworkImage( + imageUrl: photoUrl, + fit: BoxFit.cover, + width: double.infinity, + placeholder: (context, _) => Container( + color: Colors.grey.shade300, + ), + errorWidget: (context, _, __) => Container( + color: Colors.grey.shade300, + alignment: Alignment.center, + child: const Icon(Icons.broken_image_outlined), + ), + ); + } +} diff --git a/app/lib/features/deck/widgets/deck_card_skeleton.dart b/app/lib/features/deck/widgets/deck_card_skeleton.dart new file mode 100644 index 00000000..91602663 --- /dev/null +++ b/app/lib/features/deck/widgets/deck_card_skeleton.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import '../../../app/theme/spacing.dart'; + +class DeckCardSkeleton extends StatelessWidget { + const DeckCardSkeleton({super.key}); + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(AppSpacing.m), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container(height: 220, color: Colors.black12), + const SizedBox(height: AppSpacing.m), + Container(height: 20, width: 220, color: Colors.black12), + const SizedBox(height: AppSpacing.s), + Container(height: 14, width: 120, color: Colors.black12), + const SizedBox(height: AppSpacing.s), + Container(height: 14, width: 160, color: Colors.black12), + const Spacer(), + Row( + children: List.generate( + 3, + (_) => Expanded( + child: Container( + height: 36, + margin: const EdgeInsets.symmetric(horizontal: 4), + color: Colors.black12, + ), + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/deck/widgets/price_pill.dart b/app/lib/features/deck/widgets/price_pill.dart new file mode 100644 index 00000000..c056a6d0 --- /dev/null +++ b/app/lib/features/deck/widgets/price_pill.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +import '../../../core/format/formatters.dart'; + +class PricePill extends StatelessWidget { + const PricePill({required this.priceLevel, super.key}); + + final int? priceLevel; + + @override + Widget build(BuildContext context) { + return Chip( + visualDensity: VisualDensity.compact, + label: Text(formatPriceLevel(priceLevel)), + ); + } +} diff --git a/app/lib/features/deck/widgets/rating_row.dart b/app/lib/features/deck/widgets/rating_row.dart new file mode 100644 index 00000000..44ee05bc --- /dev/null +++ b/app/lib/features/deck/widgets/rating_row.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +import '../../../core/format/formatters.dart'; + +class RatingRow extends StatelessWidget { + const RatingRow({ + required this.rating, + required this.reviewCount, + super.key, + }); + + final double? rating; + final int? reviewCount; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.star, size: 16, color: Colors.amber), + const SizedBox(width: 4), + Text(formatRating(rating, reviewCount)), + ], + ); + } +} diff --git a/app/lib/features/deck/widgets/specials_badge.dart b/app/lib/features/deck/widgets/specials_badge.dart new file mode 100644 index 00000000..78af5583 --- /dev/null +++ b/app/lib/features/deck/widgets/specials_badge.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class SpecialsBadge extends StatelessWidget { + const SpecialsBadge({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.green.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: const Text('Special'), + ); + } +} diff --git a/app/lib/features/deck/widgets/sponsored_badge.dart b/app/lib/features/deck/widgets/sponsored_badge.dart new file mode 100644 index 00000000..ce14f80b --- /dev/null +++ b/app/lib/features/deck/widgets/sponsored_badge.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +class SponsoredBadge extends StatelessWidget { + const SponsoredBadge({super.key}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.orange.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Sponsored'), + const SizedBox(width: 4), + GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Sponsored placement'), + content: const Text( + 'Sponsored means this venue paid for placement.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); + }, + child: const Icon(Icons.info_outline, size: 16), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/sessions/session_page.dart b/app/lib/features/sessions/session_page.dart index fa209be2..1532a395 100644 --- a/app/lib/features/sessions/session_page.dart +++ b/app/lib/features/sessions/session_page.dart @@ -50,11 +50,7 @@ class SessionPage extends ConsumerWidget { ), const SizedBox(height: AppSpacing.m), FilledButton( - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Deck coming soon')), - ); - }, + onPressed: () => context.go('/sessions/${session.sessionId}/deck'), child: const Text('Open Deck'), ), const SizedBox(height: AppSpacing.s), diff --git a/app/lib/providers/app_providers.dart b/app/lib/providers/app_providers.dart index 9615b565..d8b196ed 100644 --- a/app/lib/providers/app_providers.dart +++ b/app/lib/providers/app_providers.dart @@ -13,6 +13,8 @@ import '../core/location/location_service.dart'; import '../core/permissions/permission_service.dart'; import '../core/sharing/share_service.dart'; import '../core/store/sessions_store.dart'; +import '../features/deck/deck_controller.dart'; +import '../features/deck/deck_state.dart'; import '../features/sessions/create_session/create_session_controller.dart'; import '../features/sessions/join_session/join_session_controller.dart'; import '../features/sessions/session_settings/session_settings_controller.dart'; @@ -148,3 +150,16 @@ final joinSessionControllerProvider = final sessionByIdProvider = FutureProvider.family((ref, sessionId) { return ref.watch(sessionsRepositoryProvider).getById(sessionId); }); + +final deckControllerProvider = + StateNotifierProvider.family((ref, sessionId) { + return DeckController( + sessionId: sessionId, + sessionsRepository: ref.watch(sessionsRepositoryProvider), + deckRepository: () => ref.read(deckRepositoryProvider.future), + telemetryRepository: () => ref.read(telemetryRepositoryProvider.future), + getLocationState: () => ref.read(locationControllerProvider), + requestPermissionAndLoadLocation: () => + ref.read(locationControllerProvider.notifier).requestPermissionAndLoad(), + ); +}); diff --git a/app/pubspec.yaml b/app/pubspec.yaml index e0724296..6e8d3a81 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -26,6 +26,9 @@ dependencies: url_launcher: ^6.3.1 share_plus: ^10.0.2 intl: ^0.19.0 + flutter_card_swiper: ^7.0.2 + cached_network_image: ^3.4.1 + collection: ^1.18.0 dev_dependencies: flutter_test: