diff --git a/lib/core/strings.dart b/lib/core/strings.dart index aeb92b55b..1376825a1 100644 --- a/lib/core/strings.dart +++ b/lib/core/strings.dart @@ -146,6 +146,7 @@ abstract final class Strings { static const emptyCoffeeCardTextBottom = 'Use the section below to shop tickets.'; static const useTicket = 'Use ticket'; + static const claimPerk = 'Claim product'; // "Buy ticket" card static const paymentOptionMobilePay = 'MobilePay'; diff --git a/lib/features/product/presentation/functions.dart b/lib/features/product/presentation/functions.dart index aea276bba..4918a4eec 100644 --- a/lib/features/product/presentation/functions.dart +++ b/lib/features/product/presentation/functions.dart @@ -70,6 +70,7 @@ Future _afterPurchaseModal( if (singleTicketPurchase) { await ticketsCubit.useTicket( product.id, + // The first eligible menu item is used as the default. product.eligibleMenuItems.first.id, ); } else { diff --git a/lib/features/product/presentation/widgets/buy_ticket_bottom_modal_sheet.dart b/lib/features/product/presentation/widgets/buy_ticket_bottom_modal_sheet.dart index 4d6b4a117..f1eadcb47 100644 --- a/lib/features/product/presentation/widgets/buy_ticket_bottom_modal_sheet.dart +++ b/lib/features/product/presentation/widgets/buy_ticket_bottom_modal_sheet.dart @@ -96,34 +96,6 @@ class _BottomModalSheetButtonBarState Widget build(BuildContext context) { final productPrice = widget.product.price; final productId = widget.product.id; - final isFreeProduct = productPrice == 0; - - if (isFreeProduct) { - return Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _BottomModalSheetButton( - text: 'Redeem product', - productId: productId, - price: productPrice, - onTap: () async { - final payment = await showPurchaseOverlay( - paymentType: InternalPaymentType.free, - product: widget.product, - context: context, - ); - - if (!context.mounted) return; - // Remove this bottom modal sheet. - Navigator.pop( - context, - payment, - ); - }, - ), - ], - ); - } return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/lib/features/product/product_model.dart b/lib/features/product/product_model.dart index 08c8f3b23..c6d5c2967 100644 --- a/lib/features/product/product_model.dart +++ b/lib/features/product/product_model.dart @@ -1,7 +1,9 @@ import 'package:coffeecard/features/product/menu_item_model.dart'; import 'package:coffeecard/generated/api/coffeecard_api_v2.models.swagger.dart'; +import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:fpdart/fpdart.dart'; +import 'package:hive_flutter/hive_flutter.dart'; class Product extends Equatable { const Product({ @@ -52,6 +54,31 @@ class Product extends Equatable { final bool isPerk; final List eligibleMenuItems; + /// The name of the Hive box used to store the last used menu item. + static const _hiveBoxName = 'lastUsedMenuItemByProductId'; + + /// Returns the last used menu item for this product as a [TaskOption]. + TaskOption get lastUsedMenuItem { + return TaskOption(() async { + final cache = await Hive.openBox(_hiveBoxName); + final lastUsedMenuItemIdFromCache = cache.get(id); + return Option.fromNullable( + eligibleMenuItems.firstWhereOrNull( + (item) => item.id == lastUsedMenuItemIdFromCache, + ), + ); + }); + } + + /// Updates the last used menu item for this product. + Task updateLastUsedMenuItem(MenuItem menuItem) { + return Task(() async { + final cache = await Hive.openBox(_hiveBoxName); + await cache.put(id, menuItem.id); + return unit; + }); + } + Product copyWith({ int? price, int? amount, diff --git a/lib/features/ticket/domain/entities/ticket.dart b/lib/features/ticket/domain/entities/ticket.dart index 764962b8c..4ab1e4dac 100644 --- a/lib/features/ticket/domain/entities/ticket.dart +++ b/lib/features/ticket/domain/entities/ticket.dart @@ -1,36 +1,26 @@ -import 'package:coffeecard/features/product/menu_item_model.dart'; import 'package:coffeecard/features/product/product_model.dart'; import 'package:equatable/equatable.dart'; -import 'package:fpdart/fpdart.dart'; class Ticket extends Equatable { const Ticket({ required this.product, required this.amountLeft, - required this.lastUsedMenuItem, }); const Ticket.empty() : product = const Product.empty(), - amountLeft = 0, - lastUsedMenuItem = const None(); + amountLeft = 0; final Product product; final int amountLeft; - final Option lastUsedMenuItem; - Ticket copyWith({ - Product? product, - int? amountLeft, - Option? lastUsedMenuItem, - }) { + Ticket copyWith({Product? product, int? amountLeft}) { return Ticket( product: product ?? this.product, amountLeft: amountLeft ?? this.amountLeft, - lastUsedMenuItem: lastUsedMenuItem ?? this.lastUsedMenuItem, ); } @override - List get props => [product, amountLeft, lastUsedMenuItem]; + List get props => [product, amountLeft]; } diff --git a/lib/features/ticket/domain/usecases/load_tickets.dart b/lib/features/ticket/domain/usecases/load_tickets.dart index a2b2f84ae..9f54d7ae6 100644 --- a/lib/features/ticket/domain/usecases/load_tickets.dart +++ b/lib/features/ticket/domain/usecases/load_tickets.dart @@ -1,12 +1,10 @@ import 'package:coffeecard/core/errors/failures.dart'; -import 'package:coffeecard/features/product/menu_item_model.dart'; import 'package:coffeecard/features/product/product_repository.dart'; import 'package:coffeecard/features/ticket/data/datasources/ticket_remote_data_source.dart'; import 'package:coffeecard/features/ticket/domain/entities/ticket.dart'; import 'package:coffeecard/generated/api/coffeecard_api_v2.models.swagger.dart'; import 'package:collection/collection.dart'; import 'package:fpdart/fpdart.dart'; -import 'package:hive_flutter/hive_flutter.dart'; class LoadTickets { final TicketRemoteDataSource ticketRemoteDataSource; @@ -35,46 +33,11 @@ class LoadTickets { final productId = ticketGroup.key; final tickets = ticketGroup.value; - return productRepository - .getProduct(productId) - .map( + return productRepository.getProduct(productId).map( (product) => Ticket( - product: product, amountLeft: tickets.length, - lastUsedMenuItem: const None(), + product: product, ), - ) - .flatMap(_addLastUsedMenuItemToTicket); - } - - /// If there is a cached last used menu item id for the given [ticket], - /// add it to the [ticket] and return it. - /// Otherwise, return the [ticket] as is. - TaskEither _addLastUsedMenuItemToTicket(Ticket ticket) { - // See if there is a cached menu item id for the given product id - final getCachedMenuItemId = TaskEither(() async { - final cache = await Hive.openBox('lastUsedMenuItemByProductId'); - return Either.fromNullable( - cache.get(ticket.product.id), - () => const LocalStorageFailure('No last used menu item found'), - ); - }); - - // Get the menu item from the product's eligible menu items - TaskEither getMenuItemFromId(lastUsedMenuItemId) { - return TaskEither.fromNullable( - ticket.product.eligibleMenuItems - .firstWhereOrNull((mi) => mi.id == lastUsedMenuItemId), - () => LocalStorageFailure( - 'Last used menu item found ($lastUsedMenuItemId), ' - 'but it is not eligible for product ${ticket.product.id}.', - ), - ); - } - - return getCachedMenuItemId - .flatMap(getMenuItemFromId) - .map((menuItem) => ticket.copyWith(lastUsedMenuItem: Some(menuItem))) - .orElse((_) => TaskEither.of(ticket)); + ); } } diff --git a/lib/features/ticket/presentation/widgets/perk_card.dart b/lib/features/ticket/presentation/widgets/perk_card.dart index cec965c50..cd86dad13 100644 --- a/lib/features/ticket/presentation/widgets/perk_card.dart +++ b/lib/features/ticket/presentation/widgets/perk_card.dart @@ -1,7 +1,9 @@ import 'package:coffeecard/core/strings.dart'; +import 'package:coffeecard/core/widgets/components/dialog.dart'; import 'package:coffeecard/features/product/presentation/functions.dart'; import 'package:coffeecard/features/product/product_model.dart'; import 'package:coffeecard/features/ticket/presentation/widgets/shop_card.dart'; +import 'package:coffeecard/features/ticket/presentation/widgets/swipe_ticket_confirm.dart'; import 'package:flutter/material.dart'; class PerkCard extends StatelessWidget { @@ -22,18 +24,50 @@ class PerkCard extends StatelessWidget { final Product product; final String price; - @override - Widget build(BuildContext context) { - return ShopCard( - title: title, - // TODO(fredpetersen): Get icon from product - icon: Icons.coffee_maker_outlined, - onTapped: (_) => buyModal( + void _onTapped(BuildContext context) { + if (product.price == 0 && product.amount == 1) { + showClaimSinglePerkConfirm( + context: context, + product: product, + ); + } else if (product.price == 0 && product.amount > 1) { + appDialog( + context: context, + title: 'Oops!', + children: [ + Text( + 'This free product would grant ${product.amount} tickets. ' + 'You cannot claim a free product that would grant multiple tickets.', + ), + ], + actions: [ + TextButton( + onPressed: () => closeAppDialog(context), + child: const Text('OK'), + ), + ], + dismissible: true, + ); + } else { + buyModal( context: context, product: product, - callback: (_, __) => Future.value(), + callback: (_, __) async {}, + ); + } + } + + @override + Widget build(BuildContext context) { + return Hero( + tag: 'perk_${product.id}', + child: ShopCard( + title: title, + // TODO(fredpetersen): Get icon from product + icon: Icons.coffee_maker_outlined, + onTapped: _onTapped, + optionalText: price, ), - optionalText: price, ); } } diff --git a/lib/features/ticket/presentation/widgets/swipe_ticket_confirm.dart b/lib/features/ticket/presentation/widgets/swipe_ticket_confirm.dart index 2f3426ab6..e6327ff40 100644 --- a/lib/features/ticket/presentation/widgets/swipe_ticket_confirm.dart +++ b/lib/features/ticket/presentation/widgets/swipe_ticket_confirm.dart @@ -7,6 +7,9 @@ import 'package:coffeecard/core/widgets/components/bottom_modal_sheet_helper.dar import 'package:coffeecard/core/widgets/components/card.dart'; import 'package:coffeecard/core/widgets/components/slide_action.dart'; import 'package:coffeecard/features/product/menu_item_model.dart'; +import 'package:coffeecard/features/product/product_model.dart'; +import 'package:coffeecard/features/purchase/domain/entities/internal_payment_type.dart'; +import 'package:coffeecard/features/purchase/presentation/widgets/purchase_overlay.dart'; import 'package:coffeecard/features/receipt/presentation/cubit/receipt_cubit.dart'; import 'package:coffeecard/features/ticket/domain/entities/ticket.dart'; import 'package:coffeecard/features/ticket/presentation/cubit/tickets_cubit.dart'; @@ -15,23 +18,67 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fpdart/fpdart.dart' hide State; import 'package:gap/gap.dart'; +Future showClaimSinglePerkConfirm({ + required BuildContext context, + required Product product, +}) { + return _showSwipeTicketConfirm( + context: context, + ticket: Ticket(product: product, amountLeft: 1), + heroTag: 'perk_${product.id}', + isPerk: true, + ); +} + Future showSwipeTicketConfirm({ - // The context is required to get cubits via context.read required BuildContext context, required Ticket ticket, }) { + return _showSwipeTicketConfirm( + context: context, + ticket: ticket, + heroTag: 'ticket_${ticket.product.id}', + isPerk: false, + ); +} + +Future _showSwipeTicketConfirm({ + // The context is required to get cubits via context.read + required BuildContext context, + required Ticket ticket, + required String heroTag, + required bool isPerk, +}) async { + final lastUsedMenuItem = await ticket.product.lastUsedMenuItem.run(); + if (!context.mounted) return null; + return Navigator.of(context, rootNavigator: true).push( _HeroDialogRoute( - builder: (_) => _ModalContent(context: context, ticket: ticket), + builder: (_) => _ModalContent( + context: context, + ticket: ticket, + lastUsedMenuItem: lastUsedMenuItem, + heroTag: heroTag, + isPerk: isPerk, + ), ), ); } class _ModalContent extends StatefulWidget { - const _ModalContent({required this.context, required this.ticket}); + const _ModalContent({ + required this.context, + required this.ticket, + required this.lastUsedMenuItem, + required this.heroTag, + required this.isPerk, + }); final BuildContext context; final Ticket ticket; + final Option lastUsedMenuItem; + final String heroTag; + final bool isPerk; @override State<_ModalContent> createState() => _ModalContentState(); @@ -43,15 +90,15 @@ class _ModalContentState extends State<_ModalContent> late Animation _fadeInAnimation; late Animation _fadeBetweenAnimation; - late Ticket _ticket = widget.ticket; + late String _heroTag = widget.heroTag; + late final Product _product = widget.ticket.product; + late Option _selectedMenuItem = widget.lastUsedMenuItem; - late _TicketUseState _state = switch (_ticket.product.eligibleMenuItems) { + late _TicketUseState _state = switch (_product.eligibleMenuItems) { [final onlyMenuItem] => _ConfirmSwipe(onlyMenuItem), _ => const _SelectProduct(), }; - late Option _selectedMenuItem = widget.ticket.lastUsedMenuItem; - @override void initState() { _controller = AnimationController( @@ -111,7 +158,7 @@ class _ModalContentState extends State<_ModalContent> ], ), Hero( - tag: _ticket, + tag: _heroTag, // SingleChildScrollView to avoid the temporary overflow // error during the hero animation. child: SingleChildScrollView( @@ -142,13 +189,16 @@ class _ModalContentState extends State<_ModalContent> } Widget get _selectProductTitle { - const description = 'Select a product to spend your ticket on'; + final description = switch (widget.isPerk) { + true => 'Select a menu item to spend your perk on', + false => 'Select a menu item to spend your ticket on', + }; return _wrapWithFadeTransition( _fadeInAnimation, child: CardTitle( title: Text( - widget.ticket.product.name, + _product.name, style: AppTextStyle.ownedTicket, ), description: Text( @@ -160,7 +210,10 @@ class _ModalContentState extends State<_ModalContent> } Widget _confirmSwipeTitle(MenuItem menuItem) { - final description = 'Claiming via ticket: ${widget.ticket.product.name}'; + final description = switch (widget.isPerk) { + true => 'Claiming via perk: ${_product.name}', + false => 'Claiming via ticket: ${_product.name}', + }; return _wrapWithFadeTransition( _fadeBetweenAnimation, @@ -172,7 +225,7 @@ class _ModalContentState extends State<_ModalContent> } Widget get _selectProductAction { - final dropdownItems = widget.ticket.product.eligibleMenuItems + final dropdownItems = _product.eligibleMenuItems .map((mi) => DropdownMenuItem(value: mi, child: Text(mi.name))) .toList(); @@ -199,7 +252,7 @@ class _ModalContentState extends State<_ModalContent> ), ), child: DropdownButton( - hint: const Text('Select a product...'), + hint: const Text('Select a menu item...'), isExpanded: true, value: _selectedMenuItem.toNullable(), items: dropdownItems, @@ -250,7 +303,7 @@ class _ModalContentState extends State<_ModalContent> const Gap(8), SlideAction( elevation: 0, - text: Strings.useTicket, + text: widget.isPerk ? Strings.claimPerk : Strings.useTicket, textStyle: AppTextStyle.buttonText.apply(color: AppColors.white), height: 56, sliderButtonIcon: const Icon(Icons.navigate_next, size: 48), @@ -259,11 +312,22 @@ class _ModalContentState extends State<_ModalContent> outerColor: AppColors.primary, onSubmit: () async { // Disable hero animation in the reverse direction - setState(() => _ticket = const Ticket.empty()); + setState(() => _heroTag = ''); + + if (widget.isPerk) { + await showPurchaseOverlay( + context: widget.context, + product: _product, + paymentType: InternalPaymentType.free, + ); + } + + if (!widget.context.mounted) return; + final ticketCubit = widget.context.read(); final receiptCubit = widget.context.read(); - final productId = widget.ticket.product.id; - await ticketCubit.useTicket(productId, menuItem.id); + await _product.updateLastUsedMenuItem(menuItem).run(); + await ticketCubit.useTicket(_product.id, menuItem.id); await receiptCubit.fetchReceipts(); }, ), diff --git a/lib/features/ticket/presentation/widgets/tickets_section.dart b/lib/features/ticket/presentation/widgets/tickets_section.dart index e29655a78..00b8399a6 100644 --- a/lib/features/ticket/presentation/widgets/tickets_section.dart +++ b/lib/features/ticket/presentation/widgets/tickets_section.dart @@ -128,7 +128,10 @@ class LoadedTicketsSection extends StatelessWidget { .map( (ticket) => Padding( padding: const EdgeInsets.only(bottom: 12.0), - child: Hero(tag: ticket, child: TicketsCard(ticket)), + child: Hero( + tag: 'ticket_${ticket.product.id}', + child: TicketsCard(ticket), + ), ), ) .toList(),