caching for cart

This commit is contained in:
Mikkel Troels Kongsted 2025-03-14 13:26:46 +01:00
parent 44ef95ba63
commit de4e91db72
19 changed files with 175 additions and 69 deletions

View File

@ -1,16 +1,38 @@
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mobile/models/cart_item.dart';
import 'package:mobile/models/product.dart'; import 'package:mobile/models/product.dart';
import 'package:mobile/results.dart'; import 'package:mobile/results.dart';
import 'package:path_provider/path_provider.dart';
class ProductIdException implements Exception {} class ProductIdException implements Exception {}
class CartController extends ChangeNotifier { abstract class CartController extends ChangeNotifier {
List<CartItem> allCartItems();
CartItem? withProductId(int productId);
void incrementAmount(int productId);
void decrementAmount(int productId);
bool willRemoveOnNextDecrement(int productId);
void removeCartItem(int productId);
void addToCart(Product product);
int totalItemsInCart();
int totalPrice();
void clearCart();
}
class CartControllerMemory extends CartController {
final List<CartItem> cart = []; final List<CartItem> cart = [];
CartControllerMemory();
@override
List<CartItem> allCartItems() { List<CartItem> allCartItems() {
return cart; return cart;
} }
@override
CartItem? withProductId(int productId) { CartItem? withProductId(int productId) {
for (var i = 0; i < cart.length; i++) { for (var i = 0; i < cart.length; i++) {
if (cart[i].product.id == productId) { if (cart[i].product.id == productId) {
@ -20,6 +42,7 @@ class CartController extends ChangeNotifier {
return null; return null;
} }
@override
void incrementAmount(int productId) { void incrementAmount(int productId) {
final cartItem = withProductId(productId); final cartItem = withProductId(productId);
if (cartItem == null) { if (cartItem == null) {
@ -29,6 +52,7 @@ class CartController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
@override
void decrementAmount(int productId) { void decrementAmount(int productId) {
final cartItem = withProductId(productId); final cartItem = withProductId(productId);
if (cartItem == null) { if (cartItem == null) {
@ -41,6 +65,7 @@ class CartController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
@override
bool willRemoveOnNextDecrement(int productId) { bool willRemoveOnNextDecrement(int productId) {
final cartItem = withProductId(productId); final cartItem = withProductId(productId);
if (cartItem == null) { if (cartItem == null) {
@ -49,6 +74,7 @@ class CartController extends ChangeNotifier {
return cartItem.amount <= 1; return cartItem.amount <= 1;
} }
@override
void removeCartItem(int productId) { void removeCartItem(int productId) {
final cartItem = withProductId(productId); final cartItem = withProductId(productId);
if (cartItem == null) { if (cartItem == null) {
@ -58,6 +84,7 @@ class CartController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
@override
void addToCart(Product product) { void addToCart(Product product) {
final cartItem = withProductId(product.id); final cartItem = withProductId(product.id);
if (cartItem == null) { if (cartItem == null) {
@ -68,17 +95,20 @@ class CartController extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
@override
int totalItemsInCart() { int totalItemsInCart() {
return cart.fold<int>(0, (prev, cartItem) => prev + cartItem.amount); return cart.fold<int>(0, (prev, cartItem) => prev + cartItem.amount);
} }
@override
int totalPrice() { int totalPrice() {
return cart.fold<int>( return cart.fold<int>(
0, 0,
(prev, cartItem) => (prev, cartItem) =>
prev + cartItem.amount * cartItem.product.priceInDkkCents); prev + cartItem.amount * cartItem.product.priceDkkCent);
} }
@override
void clearCart() { void clearCart() {
cart.clear(); cart.clear();
notifyListeners(); notifyListeners();
@ -89,9 +119,63 @@ class CartController extends ChangeNotifier {
} }
} }
class CartItem { class CartControllerCache extends CartControllerMemory {
final Product product; static Future<File> get _cacheFile async {
int amount; final directory = await getApplicationCacheDirectory();
return File("${directory.path}/cart.json").create();
}
CartItem({required this.product, required this.amount}); CartControllerCache() {
load();
}
void save() async {
final json =
jsonEncode(cart.map((cartItem) => CartItem.toJson(cartItem)).toList());
(await _cacheFile).writeAsString(json);
}
void load() async {
final json = await (await _cacheFile).readAsString();
print("Loading cache: $json");
if (json.isEmpty) {
return;
}
final res = jsonDecode(json);
final cartItems = (res as List<dynamic>)
.map(((cartItems) => CartItem.fromJson(cartItems)))
.toList();
cart.insertAll(0, cartItems);
notifyListeners();
}
@override
void incrementAmount(int productId) {
super.incrementAmount(productId);
save();
}
@override
void decrementAmount(int productId) {
super.decrementAmount(productId);
save();
}
@override
void removeCartItem(int productId) {
super.removeCartItem(productId);
save();
}
@override
void addToCart(Product product) {
super.addToCart(product);
save();
}
@override
void clearCart() {
super.clearCart();
save();
}
} }

View File

@ -23,7 +23,7 @@ class ProductController extends ChangeNotifier {
} }
} }
get filteredProducts { List<Product> get filteredProducts {
if (query.trim().isEmpty) { if (query.trim().isEmpty) {
return products; return products;
} }

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mobile/models/cart_item.dart';
import 'package:mobile/models/product.dart'; import 'package:mobile/models/product.dart';
import 'package:mobile/controllers/cart.dart';
class ReceiptController extends ChangeNotifier { class ReceiptController extends ChangeNotifier {
int nextId = 0; int nextId = 0;
@ -80,6 +80,6 @@ class ReceiptItem {
ReceiptItem({required this.product, required this.amount}); ReceiptItem({required this.product, required this.amount});
int totalPrice() { int totalPrice() {
return product.priceInDkkCents * amount; return product.priceDkkCent * amount;
} }
} }

View File

@ -29,7 +29,7 @@ class MyApp extends StatelessWidget {
ChangeNotifierProvider(create: (_) => RoutingController()), ChangeNotifierProvider(create: (_) => RoutingController()),
ChangeNotifierProvider( ChangeNotifierProvider(
create: (_) => ProductController(server: server)), create: (_) => ProductController(server: server)),
ChangeNotifierProvider(create: (_) => CartController()), ChangeNotifierProvider(create: (_) => CartControllerCache()),
ChangeNotifierProvider(create: (_) => ReceiptController()), ChangeNotifierProvider(create: (_) => ReceiptController()),
ChangeNotifierProvider(create: (_) => PayingStateController()), ChangeNotifierProvider(create: (_) => PayingStateController()),
ChangeNotifierProvider(create: (_) => AddToCartStateController()), ChangeNotifierProvider(create: (_) => AddToCartStateController()),

View File

@ -0,0 +1,19 @@
import 'package:mobile/models/product.dart';
class CartItem {
final Product product;
int amount;
CartItem({required this.product, required this.amount});
static Map<String, dynamic> toJson(CartItem cartItem) {
return {
"product": Product.toJson(cartItem.product),
"amount": cartItem.amount
};
}
CartItem.fromJson(Map<String, dynamic> json)
: product = Product.fromJson(json["product"]),
amount = json["amount"];
}

View File

@ -4,24 +4,35 @@ class Product {
final int id; final int id;
final String name; final String name;
final String description; final String description;
final int priceInDkkCents; final int priceDkkCent;
final Coordinate? location; final Coordinate? location;
final String? barcode; final String? barcode;
Product({ Product({
required this.id, required this.id,
required this.name, required this.name,
required this.priceInDkkCents, required this.priceDkkCent,
required this.description, required this.description,
this.location, this.location,
this.barcode, this.barcode,
}); });
static Map<String, dynamic> toJson(Product product) {
return {
"id": product.id,
"name": product.name,
"description": product.description,
"price_dkk_cent": product.priceDkkCent,
"location": product.location,
"barcode": product.barcode,
};
}
Product.fromJson(Map<String, dynamic> json) Product.fromJson(Map<String, dynamic> json)
: id = json["id"], : id = json["id"],
name = json["name"], name = json["name"],
description = json["description"], description = json["description"],
priceInDkkCents = json["price_dkk_cent"], priceDkkCent = json["price_dkk_cent"],
location = null, location = null,
barcode = json["barcode"]; barcode = json["barcode"];
} }

View File

@ -6,30 +6,30 @@ class User {
final String name; final String name;
// balance is in øre // balance is in øre
int balanceInDkkCents; int balanceDkkCents;
User({ User({
required this.id, required this.id,
required this.email, required this.email,
required this.name, required this.name,
required this.balanceInDkkCents, required this.balanceDkkCents,
}); });
User.fromJson(Map<String, dynamic> json) User.fromJson(Map<String, dynamic> json)
: email = json["email"], : email = json["email"],
id = json["id"], id = json["id"],
name = json["name"], name = json["name"],
balanceInDkkCents = json["balance_dkk_cent"]; balanceDkkCents = json["balance_dkk_cent"];
void addBalanceFounds(int amount) { void addBalanceFounds(int amount) {
balanceInDkkCents += amount; balanceDkkCents += amount;
} }
Result<int, String> pay(int amount) { Result<int, String> pay(int amount) {
if (balanceInDkkCents < amount) { if (balanceDkkCents < amount) {
return Err("User can not afford paying amount $amount"); return Err("User can not afford paying amount $amount");
} }
balanceInDkkCents -= amount; balanceDkkCents -= amount;
return Ok(balanceInDkkCents); return Ok(balanceDkkCents);
} }
} }

View File

@ -129,7 +129,7 @@ class AllProductsPage extends StatelessWidget {
itemBuilder: (_, idx) => ProductListItem( itemBuilder: (_, idx) => ProductListItem(
productId: products[idx].id, productId: products[idx].id,
name: products[idx].name, name: products[idx].name,
price: products[idx].priceInDkkCents, price: products[idx].priceDkkCent,
productPage: ProductPage(product: products[idx]), productPage: ProductPage(product: products[idx]),
product: products[idx], product: products[idx],
), ),

View File

@ -13,7 +13,7 @@ import 'package:mobile/widgets/sized_card.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class CartItemView extends StatelessWidget { class CartItemView extends StatelessWidget {
final CartController cartRepo; final CartControllerCache cartRepo;
final int productId; final int productId;
final String name; final String name;
final int price; final int price;
@ -150,7 +150,7 @@ class CartPage extends StatelessWidget {
return Column( return Column(
children: [ children: [
Expanded( Expanded(
child: Consumer<CartController>( child: Consumer<CartControllerCache>(
builder: (_, cartRepo, __) { builder: (_, cartRepo, __) {
final cart = cartRepo.allCartItems(); final cart = cartRepo.allCartItems();
return ListView.builder( return ListView.builder(
@ -159,7 +159,7 @@ class CartPage extends StatelessWidget {
cartRepo: cartRepo, cartRepo: cartRepo,
productId: cart[idx].product.id, productId: cart[idx].product.id,
name: cart[idx].product.name, name: cart[idx].product.name,
price: cart[idx].product.priceInDkkCents, price: cart[idx].product.priceDkkCent,
amount: cart[idx].amount), amount: cart[idx].amount),
itemCount: cart.length, itemCount: cart.length,
); );
@ -202,9 +202,9 @@ class CartPage extends StatelessWidget {
onPressed: () { onPressed: () {
final productRepo = context final productRepo = context
.read<ProductController>(); .read<ProductController>();
final CartController cartRepo = final CartControllerCache
context cartRepo = context.read<
.read<CartController>(); CartControllerCache>();
final productResult = productRepo final productResult = productRepo
.productWithBarcode( .productWithBarcode(
inputController.text); inputController.text);
@ -260,8 +260,8 @@ class CartPage extends StatelessWidget {
if (!context.mounted) { if (!context.mounted) {
return; return;
} }
final CartController cartRepo = final CartControllerCache cartRepo =
context.read<CartController>(); context.read<CartControllerCache>();
final productRepo = final productRepo =
context.read<ProductController>(); context.read<ProductController>();
final productResult = productRepo final productResult = productRepo

View File

@ -23,7 +23,7 @@ class Dashboard extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pageIndexProvider = Provider.of<RoutingController>(context); final pageIndexProvider = Provider.of<RoutingController>(context);
int currentIndex = pageIndexProvider.currentIndex; int currentIndex = pageIndexProvider.currentIndex;
final CartController cartRepo = context.watch<CartController>(); final CartControllerCache cartRepo = context.watch<CartControllerCache>();
return Scaffold( return Scaffold(
bottomNavigationBar: BottomNavigationBar( bottomNavigationBar: BottomNavigationBar(

View File

@ -14,7 +14,8 @@ class FinishShoppingPage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final CartController cartController = context.read<CartController>(); final CartControllerCache cartController =
context.read<CartControllerCache>();
final ReceiptController receiptRepo = context.read<ReceiptController>(); final ReceiptController receiptRepo = context.read<ReceiptController>();
final PayingStateController payingStateRepo = final PayingStateController payingStateRepo =
context.watch<PayingStateController>(); context.watch<PayingStateController>();
@ -33,7 +34,7 @@ class FinishShoppingPage extends StatelessWidget {
child: ListView.builder( child: ListView.builder(
shrinkWrap: true, shrinkWrap: true,
itemBuilder: (_, idx) => ReceiptItemView( itemBuilder: (_, idx) => ReceiptItemView(
pricePerAmount: cart[idx].product.priceInDkkCents, pricePerAmount: cart[idx].product.priceDkkCent,
name: cart[idx].product.name, name: cart[idx].product.name,
amount: cart[idx].amount), amount: cart[idx].amount),
itemCount: cart.length), itemCount: cart.length),

View File

@ -38,7 +38,7 @@ class HomePage extends StatelessWidget {
sessionController: sessionController, sessionController: sessionController,
placeholder: const CircularProgressIndicator(), placeholder: const CircularProgressIndicator(),
builder: (context, user) => Text( builder: (context, user) => Text(
"Saldo: ${formatDkkCents(user.balanceInDkkCents)}", "Saldo: ${formatDkkCents(user.balanceDkkCents)}",
style: Theme.of(context).textTheme.headlineSmall))), style: Theme.of(context).textTheme.headlineSmall))),
), ),
Expanded( Expanded(

View File

@ -38,7 +38,7 @@ class ProductPage extends StatelessWidget {
), ),
), ),
Text( Text(
formatDkkCents(product.priceInDkkCents), formatDkkCents(product.priceDkkCent),
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
), ),
@ -66,7 +66,7 @@ class ProductPage extends StatelessWidget {
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
Text( Text(
formatDkkCents(product.priceInDkkCents), formatDkkCents(product.priceDkkCent),
style: Theme.of(context).textTheme.bodyLarge, style: Theme.of(context).textTheme.bodyLarge,
), ),
Padding( Padding(
@ -90,7 +90,7 @@ class ProductPage extends StatelessWidget {
duration: const Duration(seconds: 2), duration: const Duration(seconds: 2),
); );
ScaffoldMessenger.of(context).removeCurrentSnackBar(); ScaffoldMessenger.of(context).removeCurrentSnackBar();
final cartRepo = context.read<CartController>(); final cartRepo = context.read<CartControllerCache>();
cartRepo.addToCart(product); cartRepo.addToCart(product);
ScaffoldMessenger.of(context).showSnackBar(snackBar); ScaffoldMessenger.of(context).showSnackBar(snackBar);
}, },

View File

@ -29,7 +29,7 @@ class ReceiptView extends StatelessWidget {
shrinkWrap: true, shrinkWrap: true,
itemBuilder: (_, idx) => ReceiptItemView( itemBuilder: (_, idx) => ReceiptItemView(
pricePerAmount: pricePerAmount:
receiptItems[idx].product.priceInDkkCents, receiptItems[idx].product.priceDkkCent,
name: receiptItems[idx].product.name, name: receiptItems[idx].product.name,
amount: receiptItems[idx].amount), amount: receiptItems[idx].amount),
itemCount: receiptItems.length), itemCount: receiptItems.length),

View File

@ -29,7 +29,7 @@ class SaldoSettingsPage extends StatelessWidget {
sessionController: sessionController, sessionController: sessionController,
placeholder: const CircularProgressIndicator(), placeholder: const CircularProgressIndicator(),
builder: (context, user) => Text( builder: (context, user) => Text(
"Nuværende saldo: ${formatDkkCents(user.balanceInDkkCents)}", "Nuværende saldo: ${formatDkkCents(user.balanceDkkCents)}",
style: Theme.of(context).textTheme.bodyLarge), style: Theme.of(context).textTheme.bodyLarge),
), ),
ElevatedButton.icon( ElevatedButton.icon(

View File

@ -11,90 +11,80 @@ class MockServer implements Server {
Product( Product(
id: nextId++, id: nextId++,
name: "Minimælk", name: "Minimælk",
priceInDkkCents: 1200, priceDkkCent: 1200,
description: "Konventionel minimælk med fedtprocent på 0,4%"), description: "Konventionel minimælk med fedtprocent på 0,4%"),
Product( Product(
id: nextId++, id: nextId++,
name: "Letmælk", name: "Letmælk",
priceInDkkCents: 1300, priceDkkCent: 1300,
description: "Konventionel letmælk med fedtprocent på 1,5%", description: "Konventionel letmælk med fedtprocent på 1,5%",
location: Coordinate(x: 1800, y: 100)), location: Coordinate(x: 1800, y: 100)),
Product( Product(
id: nextId++, id: nextId++,
name: "Frilands Øko Supermælk", name: "Frilands Øko Supermælk",
priceInDkkCents: 2000, priceDkkCent: 2000,
description: description:
"Økologisk mælk af frilandskøer med fedtprocent på 3,5%. Ikke homogeniseret eller pasteuriseret. Skaber store muskler og styrker knoglerne 💪"), "Økologisk mælk af frilandskøer med fedtprocent på 3,5%. Ikke homogeniseret eller pasteuriseret. Skaber store muskler og styrker knoglerne 💪"),
Product( Product(
id: nextId++, id: nextId++,
name: "Øko Gulerødder 1 kg", name: "Øko Gulerødder 1 kg",
priceInDkkCents: 1000, priceDkkCent: 1000,
description: ""), description: ""),
Product( Product(
id: nextId++, id: nextId++, name: "Øko Agurk", priceDkkCent: 1000, description: ""),
name: "Øko Agurk",
priceInDkkCents: 1000,
description: ""),
Product( Product(
id: nextId++, id: nextId++,
name: "Æbler 1 kg", name: "Æbler 1 kg",
priceInDkkCents: 1000, priceDkkCent: 1000,
description: ""), description: ""),
Product( Product(
id: nextId++, id: nextId++,
name: "Basmati Ris", name: "Basmati Ris",
priceInDkkCents: 2000, priceDkkCent: 2000,
description: ""), description: ""),
Product( Product(
id: nextId++, id: nextId++,
name: "Haribo Mix", name: "Haribo Mix",
priceInDkkCents: 3000, priceDkkCent: 3000,
description: ""), description: ""),
Product( Product(id: nextId++, name: "Smør", priceDkkCent: 3000, description: ""),
id: nextId++, name: "Smør", priceInDkkCents: 3000, description: ""),
Product( Product(
id: nextId++, id: nextId++,
name: "Harboe Cola", name: "Harboe Cola",
priceInDkkCents: 500, priceDkkCent: 500,
description: ""), description: ""),
Product( Product(
id: nextId++, id: nextId++,
barcode: "5060337502900", barcode: "5060337502900",
name: "Monster Energi Drik", name: "Monster Energi Drik",
priceInDkkCents: 1500, priceDkkCent: 1500,
description: ""), description: ""),
Product( Product(
id: nextId++, id: nextId++,
barcode: "5712870659220", barcode: "5712870659220",
name: "Amper Energi Drik", name: "Amper Energi Drik",
priceInDkkCents: 750, priceDkkCent: 750,
description: ""), description: ""),
Product( Product(
id: nextId++, id: nextId++,
barcode: "5710326001937", barcode: "5710326001937",
name: "Danskvand Med Brus", name: "Danskvand Med Brus",
priceInDkkCents: 500, priceDkkCent: 500,
description: "Med smag a blåbær"), description: "Med smag a blåbær"),
Product( Product(
id: nextId++, id: nextId++, name: "Spaghetti", priceDkkCent: 1000, description: ""),
name: "Spaghetti",
priceInDkkCents: 1000,
description: ""),
Product( Product(
id: nextId++, id: nextId++, name: "Rød Cecil", priceDkkCent: 6000, description: ""),
name: "Rød Cecil",
priceInDkkCents: 6000,
description: ""),
Product( Product(
id: nextId++, id: nextId++,
name: "Jägermeister 750 ml", name: "Jägermeister 750 ml",
priceInDkkCents: 12000, priceDkkCent: 12000,
description: ""), description: ""),
Product( Product(
id: nextId++, id: nextId++,
barcode: "5711953068881", barcode: "5711953068881",
name: "Protein Chokoladedrik", name: "Protein Chokoladedrik",
priceInDkkCents: 1500, priceDkkCent: 1500,
description: "Arla's protein chokolade drik der giver store muskler"), description: "Arla's protein chokolade drik der giver store muskler"),
]); ]);
} }
@ -128,7 +118,7 @@ class MockServer implements Server {
id: 0, id: 0,
email: "test@test.com", email: "test@test.com",
name: "testuser", name: "testuser",
balanceInDkkCents: 10000)); balanceDkkCents: 10000));
} }
@override @override

View File

@ -1,6 +1,6 @@
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
String formatDkkCents(int priceInDkkCents) { String formatDkkCents(int priceDkkCents) {
final formatter = NumberFormat("###,##0.00", "da_DK"); final formatter = NumberFormat("###,##0.00", "da_DK");
return "${formatter.format(priceInDkkCents / 100.0)} kr"; return "${formatter.format(priceDkkCents / 100.0)} kr";
} }

View File

@ -268,7 +268,7 @@ packages:
source: hosted source: hosted
version: "1.9.1" version: "1.9.1"
path_provider: path_provider:
dependency: transitive dependency: "direct main"
description: description:
name: path_provider name: path_provider
sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd"

View File

@ -39,6 +39,7 @@ dependencies:
google_fonts: ^6.2.1 google_fonts: ^6.2.1
intl: ^0.20.2 intl: ^0.20.2
http: ^1.3.0 http: ^1.3.0
path_provider: ^2.1.5
dev_dependencies: dev_dependencies:
flutter_test: flutter_test: