Skip to content

Commit

Permalink
feat(tickets): add menu item selection for free premium perk (#581)
Browse files Browse the repository at this point in the history
  • Loading branch information
duckth authored Dec 3, 2024
1 parent 3dc264a commit 759fcf1
Show file tree
Hide file tree
Showing 9 changed files with 163 additions and 108 deletions.
1 change: 1 addition & 0 deletions lib/core/strings.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
1 change: 1 addition & 0 deletions lib/features/product/presentation/functions.dart
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ Future<void> _afterPurchaseModal(
if (singleTicketPurchase) {
await ticketsCubit.useTicket(
product.id,
// The first eligible menu item is used as the default.
product.eligibleMenuItems.first.id,
);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Payment>(
context,
payment,
);
},
),
],
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expand Down
27 changes: 27 additions & 0 deletions lib/features/product/product_model.dart
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -52,6 +54,31 @@ class Product extends Equatable {
final bool isPerk;
final List<MenuItem> 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<MenuItem> get lastUsedMenuItem {
return TaskOption(() async {
final cache = await Hive.openBox<int>(_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<Unit> updateLastUsedMenuItem(MenuItem menuItem) {
return Task(() async {
final cache = await Hive.openBox<int>(_hiveBoxName);
await cache.put(id, menuItem.id);
return unit;
});
}

Product copyWith({
int? price,
int? amount,
Expand Down
16 changes: 3 additions & 13 deletions lib/features/ticket/domain/entities/ticket.dart
Original file line number Diff line number Diff line change
@@ -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<MenuItem> lastUsedMenuItem;

Ticket copyWith({
Product? product,
int? amountLeft,
Option<MenuItem>? lastUsedMenuItem,
}) {
Ticket copyWith({Product? product, int? amountLeft}) {
return Ticket(
product: product ?? this.product,
amountLeft: amountLeft ?? this.amountLeft,
lastUsedMenuItem: lastUsedMenuItem ?? this.lastUsedMenuItem,
);
}

@override
List<Object?> get props => [product, amountLeft, lastUsedMenuItem];
List<Object?> get props => [product, amountLeft];
}
43 changes: 3 additions & 40 deletions lib/features/ticket/domain/usecases/load_tickets.dart
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<Failure, Ticket> _addLastUsedMenuItemToTicket(Ticket ticket) {
// See if there is a cached menu item id for the given product id
final getCachedMenuItemId = TaskEither<Failure, int>(() async {
final cache = await Hive.openBox<int>('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<Failure, MenuItem> 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));
);
}
}
52 changes: 43 additions & 9 deletions lib/features/ticket/presentation/widgets/perk_card.dart
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
);
}
}
Loading

0 comments on commit 759fcf1

Please sign in to comment.