Skip to content

Commit

Permalink
feat(purchase): add guardrails for failed purchases (#576)
Browse files Browse the repository at this point in the history
1. always refresh user's tickets after purchase attempt
2. show a 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
  • Loading branch information
marfavi authored Nov 5, 2024
1 parent 4a84dd7 commit e51476e
Show file tree
Hide file tree
Showing 6 changed files with 67 additions and 37 deletions.
2 changes: 1 addition & 1 deletion lib/core/strings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
17 changes: 13 additions & 4 deletions lib/features/ticket/presentation/cubit/tickets_cubit.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,17 @@ class TicketsCubit extends Cubit<TicketsState> {
}) : super(const TicketsLoading());

Future<void> getTickets() async {
emit(const TicketsLoading());
return refreshTickets();
return _refreshTickets();
}

Future<void> refreshTickets() async {
switch (state) {
case final TicketsLoaded loaded:
emit(TicketsRefreshing(tickets: loaded.tickets));
return _refreshTickets();
default:
return;
}
}

Future<void> useTicket(int productId, int menuItemId) async {
Expand All @@ -38,10 +47,10 @@ class TicketsCubit extends Cubit<TicketsState> {
.map(emit)
.run();

return refreshTickets();
return _refreshTickets();
}

Future<void> refreshTickets() => loadTickets()
Future<void> _refreshTickets() => loadTickets()
.match(
(failure) => TicketsLoadError(message: failure.reason),
(tickets) => TicketsLoaded(tickets: tickets),
Expand Down
9 changes: 8 additions & 1 deletion lib/features/ticket/presentation/cubit/tickets_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,5 +51,12 @@ class TicketsUseError extends TicketsAction {
const TicketsUseError({required this.message, required super.tickets});

@override
List<Object?> get props => [message];
List<Object?> get props => [message, tickets];
}

class TicketsRefreshing extends TicketsLoaded {
const TicketsRefreshing({required super.tickets});

@override
List<Object?> get props => [tickets];
}
35 changes: 20 additions & 15 deletions lib/features/ticket/presentation/pages/tickets_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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<TicketsCubit>().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(),
],
),
),
),
],
],
),
),
),
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<TicketsCubit>().refreshTickets();
if (state is VoucherSuccess) return _onSuccess(context, state);
if (state is VoucherError) return _onError(context, state);
},
Expand All @@ -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<TicketsCubit>().refreshTickets();

appDialog(
context: context,
title: Strings.voucherRedeemed,
Expand Down
35 changes: 22 additions & 13 deletions test/features/ticket/presentation/cubit/tickets_cubit_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -36,25 +36,23 @@ void main() {

group('getTickets', () {
blocTest<TicketsCubit, TicketsState>(
'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<TicketsCubit, TicketsState>(
'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'),
],
);
Expand Down Expand Up @@ -131,21 +129,32 @@ void main() {
blocTest<TicketsCubit, TicketsState>(
'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<TicketsCubit, TicketsState>(
'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'),
],
);
});
}

0 comments on commit e51476e

Please sign in to comment.