This commit is contained in:
Phuoc Nguyen
2025-10-10 16:38:07 +07:00
parent e5b247d622
commit b94c158004
177 changed files with 25080 additions and 152 deletions

View File

@@ -0,0 +1,53 @@
import 'package:hive_ce/hive.dart';
import '../models/cart_item_model.dart';
/// Cart local data source using Hive
abstract class CartLocalDataSource {
Future<List<CartItemModel>> getCartItems();
Future<void> addToCart(CartItemModel item);
Future<void> updateQuantity(String productId, int quantity);
Future<void> removeFromCart(String productId);
Future<void> clearCart();
}
class CartLocalDataSourceImpl implements CartLocalDataSource {
final Box<CartItemModel> box;
CartLocalDataSourceImpl(this.box);
@override
Future<List<CartItemModel>> getCartItems() async {
return box.values.toList();
}
@override
Future<void> addToCart(CartItemModel item) async {
await box.put(item.productId, item);
}
@override
Future<void> updateQuantity(String productId, int quantity) async {
final item = box.get(productId);
if (item != null) {
final updated = CartItemModel(
productId: item.productId,
productName: item.productName,
price: item.price,
quantity: quantity,
imageUrl: item.imageUrl,
addedAt: item.addedAt,
);
await box.put(productId, updated);
}
}
@override
Future<void> removeFromCart(String productId) async {
await box.delete(productId);
}
@override
Future<void> clearCart() async {
await box.clear();
}
}

View File

@@ -0,0 +1,83 @@
import 'package:hive_ce/hive.dart';
import '../../domain/entities/cart_item.dart';
import '../../../../core/constants/storage_constants.dart';
part 'cart_item_model.g.dart';
@HiveType(typeId: StorageConstants.cartItemTypeId)
class CartItemModel extends HiveObject {
@HiveField(0)
final String productId;
@HiveField(1)
final String productName;
@HiveField(2)
final double price;
@HiveField(3)
final int quantity;
@HiveField(4)
final String? imageUrl;
@HiveField(5)
final DateTime addedAt;
CartItemModel({
required this.productId,
required this.productName,
required this.price,
required this.quantity,
this.imageUrl,
required this.addedAt,
});
/// Convert to domain entity
CartItem toEntity() {
return CartItem(
productId: productId,
productName: productName,
price: price,
quantity: quantity,
imageUrl: imageUrl,
addedAt: addedAt,
);
}
/// Create from domain entity
factory CartItemModel.fromEntity(CartItem item) {
return CartItemModel(
productId: item.productId,
productName: item.productName,
price: item.price,
quantity: item.quantity,
imageUrl: item.imageUrl,
addedAt: item.addedAt,
);
}
/// Convert to JSON
Map<String, dynamic> toJson() {
return {
'productId': productId,
'productName': productName,
'price': price,
'quantity': quantity,
'imageUrl': imageUrl,
'addedAt': addedAt.toIso8601String(),
};
}
/// Create from JSON
factory CartItemModel.fromJson(Map<String, dynamic> json) {
return CartItemModel(
productId: json['productId'] as String,
productName: json['productName'] as String,
price: (json['price'] as num).toDouble(),
quantity: json['quantity'] as int,
imageUrl: json['imageUrl'] as String?,
addedAt: DateTime.parse(json['addedAt'] as String),
);
}
}

View File

@@ -0,0 +1,56 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'cart_item_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class CartItemModelAdapter extends TypeAdapter<CartItemModel> {
@override
final typeId = 2;
@override
CartItemModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return CartItemModel(
productId: fields[0] as String,
productName: fields[1] as String,
price: (fields[2] as num).toDouble(),
quantity: (fields[3] as num).toInt(),
imageUrl: fields[4] as String?,
addedAt: fields[5] as DateTime,
);
}
@override
void write(BinaryWriter writer, CartItemModel obj) {
writer
..writeByte(6)
..writeByte(0)
..write(obj.productId)
..writeByte(1)
..write(obj.productName)
..writeByte(2)
..write(obj.price)
..writeByte(3)
..write(obj.quantity)
..writeByte(4)
..write(obj.imageUrl)
..writeByte(5)
..write(obj.addedAt);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is CartItemModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,123 @@
import 'package:hive_ce/hive.dart';
import 'package:retail/core/constants/storage_constants.dart';
import 'package:retail/features/home/data/models/cart_item_model.dart';
part 'transaction_model.g.dart';
/// Transaction model with Hive CE type adapter
@HiveType(typeId: StorageConstants.transactionTypeId)
class TransactionModel extends HiveObject {
/// Unique transaction identifier
@HiveField(0)
final String id;
/// List of cart items in this transaction
@HiveField(1)
final List<CartItemModel> items;
/// Subtotal amount (before tax and discount)
@HiveField(2)
final double subtotal;
/// Tax amount
@HiveField(3)
final double tax;
/// Discount amount
@HiveField(4)
final double discount;
/// Total amount (subtotal + tax - discount)
@HiveField(5)
final double total;
/// Transaction completion timestamp
@HiveField(6)
final DateTime completedAt;
/// Payment method used (e.g., 'cash', 'card', 'digital')
@HiveField(7)
final String paymentMethod;
TransactionModel({
required this.id,
required this.items,
required this.subtotal,
required this.tax,
required this.discount,
required this.total,
required this.completedAt,
required this.paymentMethod,
});
/// Get total number of items in transaction
int get totalItems => items.fold(0, (sum, item) => sum + item.quantity);
/// Create a copy with updated fields
TransactionModel copyWith({
String? id,
List<CartItemModel>? items,
double? subtotal,
double? tax,
double? discount,
double? total,
DateTime? completedAt,
String? paymentMethod,
}) {
return TransactionModel(
id: id ?? this.id,
items: items ?? this.items,
subtotal: subtotal ?? this.subtotal,
tax: tax ?? this.tax,
discount: discount ?? this.discount,
total: total ?? this.total,
completedAt: completedAt ?? this.completedAt,
paymentMethod: paymentMethod ?? this.paymentMethod,
);
}
/// Convert to JSON
Map<String, dynamic> toJson() {
return {
'id': id,
'items': items.map((item) => item.toJson()).toList(),
'subtotal': subtotal,
'tax': tax,
'discount': discount,
'total': total,
'completedAt': completedAt.toIso8601String(),
'paymentMethod': paymentMethod,
};
}
/// Create from JSON
factory TransactionModel.fromJson(Map<String, dynamic> json) {
return TransactionModel(
id: json['id'] as String,
items: (json['items'] as List)
.map((item) => CartItemModel.fromJson(item as Map<String, dynamic>))
.toList(),
subtotal: (json['subtotal'] as num).toDouble(),
tax: (json['tax'] as num).toDouble(),
discount: (json['discount'] as num).toDouble(),
total: (json['total'] as num).toDouble(),
completedAt: DateTime.parse(json['completedAt'] as String),
paymentMethod: json['paymentMethod'] as String,
);
}
@override
String toString() {
return 'TransactionModel(id: $id, total: $total, items: ${items.length}, method: $paymentMethod)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is TransactionModel && other.id == id;
}
@override
int get hashCode => id.hashCode;
}

View File

@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'transaction_model.dart';
// **************************************************************************
// TypeAdapterGenerator
// **************************************************************************
class TransactionModelAdapter extends TypeAdapter<TransactionModel> {
@override
final typeId = 3;
@override
TransactionModel read(BinaryReader reader) {
final numOfFields = reader.readByte();
final fields = <int, dynamic>{
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
};
return TransactionModel(
id: fields[0] as String,
items: (fields[1] as List).cast<CartItemModel>(),
subtotal: (fields[2] as num).toDouble(),
tax: (fields[3] as num).toDouble(),
discount: (fields[4] as num).toDouble(),
total: (fields[5] as num).toDouble(),
completedAt: fields[6] as DateTime,
paymentMethod: fields[7] as String,
);
}
@override
void write(BinaryWriter writer, TransactionModel obj) {
writer
..writeByte(8)
..writeByte(0)
..write(obj.id)
..writeByte(1)
..write(obj.items)
..writeByte(2)
..write(obj.subtotal)
..writeByte(3)
..write(obj.tax)
..writeByte(4)
..write(obj.discount)
..writeByte(5)
..write(obj.total)
..writeByte(6)
..write(obj.completedAt)
..writeByte(7)
..write(obj.paymentMethod);
}
@override
int get hashCode => typeId.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TransactionModelAdapter &&
runtimeType == other.runtimeType &&
typeId == other.typeId;
}

View File

@@ -0,0 +1,66 @@
import 'package:dartz/dartz.dart';
import '../../domain/entities/cart_item.dart';
import '../../domain/repositories/cart_repository.dart';
import '../datasources/cart_local_datasource.dart';
import '../models/cart_item_model.dart';
import '../../../../core/errors/failures.dart';
import '../../../../core/errors/exceptions.dart';
class CartRepositoryImpl implements CartRepository {
final CartLocalDataSource localDataSource;
CartRepositoryImpl({
required this.localDataSource,
});
@override
Future<Either<Failure, List<CartItem>>> getCartItems() async {
try {
final items = await localDataSource.getCartItems();
return Right(items.map((model) => model.toEntity()).toList());
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
@override
Future<Either<Failure, void>> addToCart(CartItem item) async {
try {
final model = CartItemModel.fromEntity(item);
await localDataSource.addToCart(model);
return const Right(null);
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
@override
Future<Either<Failure, void>> updateQuantity(String productId, int quantity) async {
try {
await localDataSource.updateQuantity(productId, quantity);
return const Right(null);
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
@override
Future<Either<Failure, void>> removeFromCart(String productId) async {
try {
await localDataSource.removeFromCart(productId);
return const Right(null);
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
@override
Future<Either<Failure, void>> clearCart() async {
try {
await localDataSource.clearCart();
return const Right(null);
} on CacheException catch (e) {
return Left(CacheFailure(e.message));
}
}
}

View File

@@ -0,0 +1,50 @@
import 'package:equatable/equatable.dart';
/// Cart item domain entity
class CartItem extends Equatable {
final String productId;
final String productName;
final double price;
final int quantity;
final String? imageUrl;
final DateTime addedAt;
const CartItem({
required this.productId,
required this.productName,
required this.price,
required this.quantity,
this.imageUrl,
required this.addedAt,
});
double get total => price * quantity;
CartItem copyWith({
String? productId,
String? productName,
double? price,
int? quantity,
String? imageUrl,
DateTime? addedAt,
}) {
return CartItem(
productId: productId ?? this.productId,
productName: productName ?? this.productName,
price: price ?? this.price,
quantity: quantity ?? this.quantity,
imageUrl: imageUrl ?? this.imageUrl,
addedAt: addedAt ?? this.addedAt,
);
}
@override
List<Object?> get props => [
productId,
productName,
price,
quantity,
imageUrl,
addedAt,
];
}

View File

@@ -0,0 +1,21 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/cart_item.dart';
/// Cart repository interface
abstract class CartRepository {
/// Get all cart items
Future<Either<Failure, List<CartItem>>> getCartItems();
/// Add item to cart
Future<Either<Failure, void>> addToCart(CartItem item);
/// Update cart item quantity
Future<Either<Failure, void>> updateQuantity(String productId, int quantity);
/// Remove item from cart
Future<Either<Failure, void>> removeFromCart(String productId);
/// Clear all cart items
Future<Either<Failure, void>> clearCart();
}

View File

@@ -0,0 +1,15 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../entities/cart_item.dart';
import '../repositories/cart_repository.dart';
/// Use case to add item to cart
class AddToCart {
final CartRepository repository;
AddToCart(this.repository);
Future<Either<Failure, void>> call(CartItem item) async {
return await repository.addToCart(item);
}
}

View File

@@ -0,0 +1,8 @@
import '../entities/cart_item.dart';
/// Use case to calculate cart total
class CalculateTotal {
double call(List<CartItem> items) {
return items.fold(0.0, (sum, item) => sum + item.total);
}
}

View File

@@ -0,0 +1,14 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../repositories/cart_repository.dart';
/// Use case to clear cart
class ClearCart {
final CartRepository repository;
ClearCart(this.repository);
Future<Either<Failure, void>> call() async {
return await repository.clearCart();
}
}

View File

@@ -0,0 +1,14 @@
import 'package:dartz/dartz.dart';
import '../../../../core/errors/failures.dart';
import '../repositories/cart_repository.dart';
/// Use case to remove item from cart
class RemoveFromCart {
final CartRepository repository;
RemoveFromCart(this.repository);
Future<Either<Failure, void>> call(String productId) async {
return await repository.removeFromCart(productId);
}
}

View File

@@ -0,0 +1,173 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../widgets/product_selector.dart';
import '../widgets/cart_summary.dart';
import '../providers/cart_provider.dart';
import '../../domain/entities/cart_item.dart';
/// Home page - POS interface with product selector and cart
class HomePage extends ConsumerWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cartAsync = ref.watch(cartProvider);
final isWideScreen = MediaQuery.of(context).size.width > 600;
return Scaffold(
appBar: AppBar(
title: const Text('Point of Sale'),
actions: [
// Cart item count badge
cartAsync.whenOrNull(
data: (items) => items.isNotEmpty
? Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Center(
child: Badge(
label: Text('${items.length}'),
child: const Icon(Icons.shopping_cart),
),
),
)
: null,
) ?? const SizedBox.shrink(),
],
),
body: isWideScreen
? Row(
children: [
// Product selector on left
Expanded(
flex: 3,
child: ProductSelector(
onProductTap: (product) {
_showAddToCartDialog(context, ref, product);
},
),
),
// Divider
const VerticalDivider(width: 1),
// Cart on right
const Expanded(
flex: 2,
child: CartSummary(),
),
],
)
: Column(
children: [
// Product selector on top
Expanded(
flex: 2,
child: ProductSelector(
onProductTap: (product) {
_showAddToCartDialog(context, ref, product);
},
),
),
// Divider
const Divider(height: 1),
// Cart on bottom
const Expanded(
flex: 3,
child: CartSummary(),
),
],
),
);
}
void _showAddToCartDialog(
BuildContext context,
WidgetRef ref,
dynamic product,
) {
int quantity = 1;
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: const Text('Add to Cart'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
product.name,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: quantity > 1
? () => setState(() => quantity--)
: null,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
'$quantity',
style: Theme.of(context).textTheme.headlineSmall,
),
),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: quantity < product.stockQuantity
? () => setState(() => quantity++)
: null,
),
],
),
if (product.stockQuantity < 5)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'Only ${product.stockQuantity} in stock',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
FilledButton.icon(
onPressed: () {
// Create cart item from product
final cartItem = CartItem(
productId: product.id,
productName: product.name,
price: product.price,
quantity: quantity,
imageUrl: product.imageUrl,
addedAt: DateTime.now(),
);
// Add to cart
ref.read(cartProvider.notifier).addItem(cartItem);
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Added ${product.name} to cart'),
duration: const Duration(seconds: 2),
),
);
},
icon: const Icon(Icons.add_shopping_cart),
label: const Text('Add'),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,27 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'cart_provider.dart';
part 'cart_item_count_provider.g.dart';
/// Provider that calculates total number of items in cart
/// This is optimized to only rebuild when the count changes
@riverpod
int cartItemCount(Ref ref) {
final itemsAsync = ref.watch(cartProvider);
return itemsAsync.when(
data: (items) => items.fold<int>(0, (sum, item) => sum + item.quantity),
loading: () => 0,
error: (_, __) => 0,
);
}
/// Provider that calculates unique items count in cart
@riverpod
int cartUniqueItemCount(Ref ref) {
final itemsAsync = ref.watch(cartProvider);
return itemsAsync.when(
data: (items) => items.length,
loading: () => 0,
error: (_, __) => 0,
);
}

View File

@@ -0,0 +1,104 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'cart_item_count_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provider that calculates total number of items in cart
/// This is optimized to only rebuild when the count changes
@ProviderFor(cartItemCount)
const cartItemCountProvider = CartItemCountProvider._();
/// Provider that calculates total number of items in cart
/// This is optimized to only rebuild when the count changes
final class CartItemCountProvider extends $FunctionalProvider<int, int, int>
with $Provider<int> {
/// Provider that calculates total number of items in cart
/// This is optimized to only rebuild when the count changes
const CartItemCountProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'cartItemCountProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$cartItemCountHash();
@$internal
@override
$ProviderElement<int> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
int create(Ref ref) {
return cartItemCount(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(int value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<int>(value),
);
}
}
String _$cartItemCountHash() => r'78fe81648a02fb84477df3be3f08b27caa039203';
/// Provider that calculates unique items count in cart
@ProviderFor(cartUniqueItemCount)
const cartUniqueItemCountProvider = CartUniqueItemCountProvider._();
/// Provider that calculates unique items count in cart
final class CartUniqueItemCountProvider
extends $FunctionalProvider<int, int, int>
with $Provider<int> {
/// Provider that calculates unique items count in cart
const CartUniqueItemCountProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'cartUniqueItemCountProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$cartUniqueItemCountHash();
@$internal
@override
$ProviderElement<int> $createElement($ProviderPointer pointer) =>
$ProviderElement(pointer);
@override
int create(Ref ref) {
return cartUniqueItemCount(ref);
}
/// {@macro riverpod.override_with_value}
Override overrideWithValue(int value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<int>(value),
);
}
}
String _$cartUniqueItemCountHash() =>
r'51eec092c957d0d4819200fd935115db77c7f8d3';

View File

@@ -0,0 +1,54 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../../domain/entities/cart_item.dart';
part 'cart_provider.g.dart';
/// Provider for shopping cart
@riverpod
class Cart extends _$Cart {
@override
Future<List<CartItem>> build() async {
// TODO: Implement with repository
return [];
}
Future<void> addItem(CartItem item) async {
// TODO: Implement add to cart
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final currentItems = state.value ?? [];
return [...currentItems, item];
});
}
Future<void> removeItem(String productId) async {
// TODO: Implement remove from cart
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final currentItems = state.value ?? [];
return currentItems.where((item) => item.productId != productId).toList();
});
}
Future<void> updateQuantity(String productId, int quantity) async {
// TODO: Implement update quantity
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
final currentItems = state.value ?? [];
return currentItems.map((item) {
if (item.productId == productId) {
return item.copyWith(quantity: quantity);
}
return item;
}).toList();
});
}
Future<void> clearCart() async {
// TODO: Implement clear cart
state = const AsyncValue.loading();
state = await AsyncValue.guard(() async {
return [];
});
}
}

View File

@@ -0,0 +1,59 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'cart_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Provider for shopping cart
@ProviderFor(Cart)
const cartProvider = CartProvider._();
/// Provider for shopping cart
final class CartProvider extends $AsyncNotifierProvider<Cart, List<CartItem>> {
/// Provider for shopping cart
const CartProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'cartProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$cartHash();
@$internal
@override
Cart create() => Cart();
}
String _$cartHash() => r'0136ac2c2a04412a130184e30c01e33a17b0d4db';
/// Provider for shopping cart
abstract class _$Cart extends $AsyncNotifier<List<CartItem>> {
FutureOr<List<CartItem>> build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<AsyncValue<List<CartItem>>, List<CartItem>>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<AsyncValue<List<CartItem>>, List<CartItem>>,
AsyncValue<List<CartItem>>,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,83 @@
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'cart_provider.dart';
import '../../../settings/presentation/providers/settings_provider.dart';
part 'cart_total_provider.g.dart';
/// Cart totals calculation provider
@riverpod
class CartTotal extends _$CartTotal {
@override
CartTotalData build() {
final itemsAsync = ref.watch(cartProvider);
final settingsAsync = ref.watch(settingsProvider);
final items = itemsAsync.when(
data: (data) => data,
loading: () => <dynamic>[],
error: (_, __) => <dynamic>[],
);
final settings = settingsAsync.when(
data: (data) => data,
loading: () => null,
error: (_, __) => null,
);
// Calculate subtotal
final subtotal = items.fold<double>(
0.0,
(sum, item) => sum + item.lineTotal,
);
// Calculate tax
final taxRate = settings?.taxRate ?? 0.0;
final tax = subtotal * taxRate;
// Calculate total
final total = subtotal + tax;
return CartTotalData(
subtotal: subtotal,
tax: tax,
taxRate: taxRate,
total: total,
itemCount: items.length,
);
}
/// Apply discount amount to total
double applyDiscount(double discountAmount) {
final currentTotal = state.total;
return (currentTotal - discountAmount).clamp(0.0, double.infinity);
}
/// Apply discount percentage to total
double applyDiscountPercentage(double discountPercent) {
final currentTotal = state.total;
final discountAmount = currentTotal * (discountPercent / 100);
return (currentTotal - discountAmount).clamp(0.0, double.infinity);
}
}
/// Cart total data model
class CartTotalData {
final double subtotal;
final double tax;
final double taxRate;
final double total;
final int itemCount;
const CartTotalData({
required this.subtotal,
required this.tax,
required this.taxRate,
required this.total,
required this.itemCount,
});
@override
String toString() {
return 'CartTotalData(subtotal: $subtotal, tax: $tax, total: $total, items: $itemCount)';
}
}

View File

@@ -0,0 +1,68 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'cart_total_provider.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint, type=warning
/// Cart totals calculation provider
@ProviderFor(CartTotal)
const cartTotalProvider = CartTotalProvider._();
/// Cart totals calculation provider
final class CartTotalProvider
extends $NotifierProvider<CartTotal, CartTotalData> {
/// Cart totals calculation provider
const CartTotalProvider._()
: super(
from: null,
argument: null,
retry: null,
name: r'cartTotalProvider',
isAutoDispose: true,
dependencies: null,
$allTransitiveDependencies: null,
);
@override
String debugGetCreateSourceHash() => _$cartTotalHash();
@$internal
@override
CartTotal create() => CartTotal();
/// {@macro riverpod.override_with_value}
Override overrideWithValue(CartTotalData value) {
return $ProviderOverride(
origin: this,
providerOverride: $SyncValueProvider<CartTotalData>(value),
);
}
}
String _$cartTotalHash() => r'044f6d4749eec49f9ef4173fc42d149a3841df21';
/// Cart totals calculation provider
abstract class _$CartTotal extends $Notifier<CartTotalData> {
CartTotalData build();
@$mustCallSuper
@override
void runBuild() {
final created = build();
final ref = this.ref as $Ref<CartTotalData, CartTotalData>;
final element =
ref.element
as $ClassProviderElement<
AnyNotifier<CartTotalData, CartTotalData>,
CartTotalData,
Object?,
Object?
>;
element.handleValue(ref, created);
}
}

View File

@@ -0,0 +1,4 @@
/// Export all home/cart providers
export 'cart_provider.dart';
export 'cart_total_provider.dart';
export 'cart_item_count_provider.dart';

View File

@@ -0,0 +1,67 @@
import 'package:flutter/material.dart';
import '../../domain/entities/cart_item.dart';
import '../../../../shared/widgets/price_display.dart';
/// Cart item card widget
class CartItemCard extends StatelessWidget {
final CartItem item;
final VoidCallback? onRemove;
final Function(int)? onQuantityChanged;
const CartItemCard({
super.key,
required this.item,
this.onRemove,
this.onQuantityChanged,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.productName,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 4),
PriceDisplay(price: item.price),
],
),
),
Row(
children: [
IconButton(
icon: const Icon(Icons.remove_circle_outline),
onPressed: item.quantity > 1
? () => onQuantityChanged?.call(item.quantity - 1)
: null,
),
Text(
'${item.quantity}',
style: Theme.of(context).textTheme.titleMedium,
),
IconButton(
icon: const Icon(Icons.add_circle_outline),
onPressed: () => onQuantityChanged?.call(item.quantity + 1),
),
],
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: onRemove,
color: Theme.of(context).colorScheme.error,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,128 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../providers/cart_provider.dart';
import '../providers/cart_total_provider.dart';
import 'cart_item_card.dart';
import '../../../../shared/widgets/price_display.dart';
import '../../../../core/widgets/empty_state.dart';
/// Cart summary widget
class CartSummary extends ConsumerWidget {
const CartSummary({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final cartAsync = ref.watch(cartProvider);
final totalData = ref.watch(cartTotalProvider);
return Container(
decoration: BoxDecoration(
border: Border(
top: BorderSide(
color: Theme.of(context).dividerColor,
width: 1,
),
),
),
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Shopping Cart',
style: Theme.of(context).textTheme.titleLarge,
),
if (cartAsync.value?.isNotEmpty ?? false)
TextButton.icon(
onPressed: () {
ref.read(cartProvider.notifier).clearCart();
},
icon: const Icon(Icons.delete_sweep),
label: const Text('Clear'),
),
],
),
),
Expanded(
child: cartAsync.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, stack) => Center(child: Text('Error: $error')),
data: (items) {
if (items.isEmpty) {
return const EmptyState(
message: 'Cart is empty',
subMessage: 'Add products to get started',
icon: Icons.shopping_cart_outlined,
);
}
return ListView.builder(
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return CartItemCard(
item: item,
onRemove: () {
ref.read(cartProvider.notifier).removeItem(item.productId);
},
onQuantityChanged: (quantity) {
ref.read(cartProvider.notifier).updateQuantity(
item.productId,
quantity,
);
},
);
},
);
},
),
),
Container(
padding: const EdgeInsets.all(16.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'Total:',
style: Theme.of(context).textTheme.titleLarge,
),
PriceDisplay(
price: totalData.total,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: (cartAsync.value?.isNotEmpty ?? false)
? () {
// TODO: Implement checkout
}
: null,
icon: const Icon(Icons.payment),
label: const Text('Checkout'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.all(16),
),
),
),
],
),
),
],
),
);
}
}

View File

@@ -0,0 +1,98 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../products/presentation/providers/products_provider.dart';
import '../../../products/presentation/widgets/product_card.dart';
import '../../../products/domain/entities/product.dart';
import '../../../../core/widgets/loading_indicator.dart';
import '../../../../core/widgets/error_widget.dart';
import '../../../../core/widgets/empty_state.dart';
/// Product selector widget for POS
class ProductSelector extends ConsumerWidget {
final void Function(Product)? onProductTap;
const ProductSelector({
super.key,
this.onProductTap,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final productsAsync = ref.watch(productsProvider);
return Container(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Select Products',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 16),
Expanded(
child: productsAsync.when(
loading: () => const LoadingIndicator(
message: 'Loading products...',
),
error: (error, stack) => ErrorDisplay(
message: error.toString(),
onRetry: () => ref.refresh(productsProvider),
),
data: (products) {
if (products.isEmpty) {
return const EmptyState(
message: 'No products available',
subMessage: 'Add products to start selling',
icon: Icons.inventory_2_outlined,
);
}
// Filter only available products for POS
final availableProducts =
products.where((p) => p.isAvailable).toList();
if (availableProducts.isEmpty) {
return const EmptyState(
message: 'No products available',
subMessage: 'All products are currently unavailable',
icon: Icons.inventory_2_outlined,
);
}
return LayoutBuilder(
builder: (context, constraints) {
// Determine grid columns based on width
int crossAxisCount = 2;
if (constraints.maxWidth > 800) {
crossAxisCount = 4;
} else if (constraints.maxWidth > 600) {
crossAxisCount = 3;
}
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
childAspectRatio: 0.75,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
),
itemCount: availableProducts.length,
itemBuilder: (context, index) {
final product = availableProducts[index];
return GestureDetector(
onTap: () => onProductTap?.call(product),
child: ProductCard(product: product),
);
},
);
},
);
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,5 @@
// Home/Cart Feature Widgets
export 'cart_item_card.dart';
export 'cart_summary.dart';
// This file provides a central export point for all home/cart widgets