Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(purchase): add guardrails for failed purchases #576

Merged
merged 1 commit into from
Nov 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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'),
],
);
});
}
Loading