From 146573c71e4681e5e81c853212f4d994e8ae8966 Mon Sep 17 00:00:00 2001 From: Omid Marfavi <21163286+marfavi@users.noreply.github.com> Date: Tue, 5 Nov 2024 21:18:03 +0100 Subject: [PATCH] feat(purchase): add guardrails for failed purchases 1. always refresh after purchase attempt 2. more open-ended error message if the purchase was cancelled/rejected to urge double-checking on user's end 3. enable pull to refresh on the Tickets page --- lib/core/strings.dart | 2 +- .../presentation/cubit/tickets_cubit.dart | 17 ++++++--- .../presentation/cubit/tickets_state.dart | 9 ++++- .../presentation/pages/tickets_page.dart | 35 +++++++++++-------- .../pages/redeem_voucher_page.dart | 6 ++-- .../cubit/tickets_cubit_test.dart | 35 ++++++++++++------- 6 files changed, 67 insertions(+), 37 deletions(-) diff --git a/lib/core/strings.dart b/lib/core/strings.dart index 5e165ddfe..aeb92b55b 100644 --- a/lib/core/strings.dart +++ b/lib/core/strings.dart @@ -193,7 +193,7 @@ abstract final class Strings { static const purchaseSuccess = 'Success'; static const purchaseRejectedOrCanceled = 'Payment rejected or canceled'; static const purchaseRejectedOrCanceledMessage = - 'The payment was rejected or cancelled. No tickets have been added to your account'; + 'Seems like the payment was rejected or cancelled. Please double check that the purchase was cancelled on MobilePay.'; static const purchaseError = "Uh oh, we couldn't complete that purchase"; static const purchaseTimeout = 'Purchase timed out'; static const purchaseTimeoutMessage = diff --git a/lib/features/ticket/presentation/cubit/tickets_cubit.dart b/lib/features/ticket/presentation/cubit/tickets_cubit.dart index 6b8b02829..a755e61a6 100644 --- a/lib/features/ticket/presentation/cubit/tickets_cubit.dart +++ b/lib/features/ticket/presentation/cubit/tickets_cubit.dart @@ -17,8 +17,17 @@ class TicketsCubit extends Cubit { }) : super(const TicketsLoading()); Future getTickets() async { - emit(const TicketsLoading()); - return refreshTickets(); + return _refreshTickets(); + } + + Future refreshTickets() async { + switch (state) { + case final TicketsLoaded loaded: + emit(TicketsRefreshing(tickets: loaded.tickets)); + return _refreshTickets(); + default: + return; + } } Future useTicket(int productId, int menuItemId) async { @@ -38,10 +47,10 @@ class TicketsCubit extends Cubit { .map(emit) .run(); - return refreshTickets(); + return _refreshTickets(); } - Future refreshTickets() => loadTickets() + Future _refreshTickets() => loadTickets() .match( (failure) => TicketsLoadError(message: failure.reason), (tickets) => TicketsLoaded(tickets: tickets), diff --git a/lib/features/ticket/presentation/cubit/tickets_state.dart b/lib/features/ticket/presentation/cubit/tickets_state.dart index e0c0dff8c..53cfcf03e 100644 --- a/lib/features/ticket/presentation/cubit/tickets_state.dart +++ b/lib/features/ticket/presentation/cubit/tickets_state.dart @@ -51,5 +51,12 @@ class TicketsUseError extends TicketsAction { const TicketsUseError({required this.message, required super.tickets}); @override - List get props => [message]; + List get props => [message, tickets]; +} + +class TicketsRefreshing extends TicketsLoaded { + const TicketsRefreshing({required super.tickets}); + + @override + List get props => [tickets]; } diff --git a/lib/features/ticket/presentation/pages/tickets_page.dart b/lib/features/ticket/presentation/pages/tickets_page.dart index db4667955..8b99fd0ed 100644 --- a/lib/features/ticket/presentation/pages/tickets_page.dart +++ b/lib/features/ticket/presentation/pages/tickets_page.dart @@ -2,6 +2,7 @@ import 'package:coffeecard/core/strings.dart'; import 'package:coffeecard/core/widgets/components/barista_perks_section.dart'; import 'package:coffeecard/core/widgets/components/scaffold.dart'; import 'package:coffeecard/features/product/purchasable_products.dart'; +import 'package:coffeecard/features/ticket/presentation/cubit/tickets_cubit.dart'; import 'package:coffeecard/features/ticket/presentation/widgets/shop_section.dart'; import 'package:coffeecard/features/ticket/presentation/widgets/tickets_section.dart'; import 'package:coffeecard/features/user/presentation/cubit/user_cubit.dart'; @@ -28,22 +29,26 @@ class TicketsPage extends StatelessWidget { return UpgradeAlert( child: AppScaffold.withTitle( title: Strings.ticketsPageTitle, - body: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Expanded( - child: ListView( - controller: scrollController, - shrinkWrap: true, - padding: const EdgeInsets.all(16.0), - children: [ - const TicketSection(), - if (perksAvailable) BaristaPerksSection(userRole: user.role), - const ShopSection(), - ], + body: RefreshIndicator( + onRefresh: context.read().getTickets, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded( + child: ListView( + controller: scrollController, + shrinkWrap: true, + padding: const EdgeInsets.all(16.0), + children: [ + const TicketSection(), + if (perksAvailable) + BaristaPerksSection(userRole: user.role), + const ShopSection(), + ], + ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/features/voucher/presentation/pages/redeem_voucher_page.dart b/lib/features/voucher/presentation/pages/redeem_voucher_page.dart index 342368af3..56617f1ad 100644 --- a/lib/features/voucher/presentation/pages/redeem_voucher_page.dart +++ b/lib/features/voucher/presentation/pages/redeem_voucher_page.dart @@ -30,6 +30,9 @@ class RedeemVoucherPage extends StatelessWidget { return; } final _ = LoadingOverlay.hide(context); + // Refresh tickets, so the user sees the redeemed ticket(s) + // (we also refresh tickets in case of failure as a fail-safe) + context.read().refreshTickets(); if (state is VoucherSuccess) return _onSuccess(context, state); if (state is VoucherError) return _onError(context, state); }, @@ -40,9 +43,6 @@ class RedeemVoucherPage extends StatelessWidget { } void _onSuccess(BuildContext context, VoucherSuccess state) { - // Refresh tickets, so the user sees the redeemed ticket(s) - context.read().refreshTickets(); - appDialog( context: context, title: Strings.voucherRedeemed, diff --git a/test/features/ticket/presentation/cubit/tickets_cubit_test.dart b/test/features/ticket/presentation/cubit/tickets_cubit_test.dart index a41089675..8b1ac8ffd 100644 --- a/test/features/ticket/presentation/cubit/tickets_cubit_test.dart +++ b/test/features/ticket/presentation/cubit/tickets_cubit_test.dart @@ -36,25 +36,23 @@ void main() { group('getTickets', () { blocTest( - 'should emit [Loading, Loaded] when use case succeeds', + 'should emit [Loaded] when use case succeeds', build: () => cubit, setUp: () => when(loadTickets()).thenAnswer((_) => TaskEither.right([])), act: (_) => cubit.getTickets(), expect: () => [ - const TicketsLoading(), const TicketsLoaded(tickets: []), ], ); blocTest( - 'should emit [Loading, Error] when use case fails', + 'should emit [Error] when use case fails', build: () => cubit, setUp: () => when(loadTickets()).thenAnswer( (_) => TaskEither.left(const ServerFailure('some error', 500)), ), act: (_) => cubit.getTickets(), expect: () => [ - const TicketsLoading(), const TicketsLoadError(message: 'some error'), ], ); @@ -131,21 +129,32 @@ void main() { blocTest( 'should emit [Loaded] when use case succeeds', build: () => cubit, - setUp: () => when(loadTickets()).thenAnswer((_) => TaskEither.right([])), - act: (_) => cubit.refreshTickets(), - expect: () => [ - const TicketsLoaded(tickets: []), + setUp: () async { + when(loadTickets()).thenAnswer((_) => TaskEither.right([])); + await cubit.getTickets(); + }, + act: (cubit) => cubit.refreshTickets(), + expect: () => const [ + TicketsRefreshing(tickets: []), + TicketsLoaded(tickets: []), ], ); blocTest( 'should emit [Error] when use case fails', build: () => cubit, - setUp: () => when(loadTickets()).thenAnswer( - (_) => TaskEither.left(const ServerFailure('some error', 500)), - ), - act: (_) => cubit.refreshTickets(), - expect: () => [const TicketsLoadError(message: 'some error')], + setUp: () async { + when(loadTickets()).thenAnswer((_) => TaskEither.right([])); + await cubit.getTickets(); + when(loadTickets()).thenAnswer( + (_) => TaskEither.left(const ServerFailure('some error', 500)), + ); + }, + act: (cubit) => cubit.refreshTickets(), + expect: () => const [ + TicketsRefreshing(tickets: []), + TicketsLoadError(message: 'some error'), + ], ); }); }