Cashier System
Sistem kasir MStore adalah fitur utama untuk melakukan transaksi penjualan dengan interface yang cepat dan intuitif.๐ฏ Overview
Cashier system menyediakan:- โ Pencarian produk real-time
- โ Barcode scanning
- โ Keranjang belanja dengan quantity management
- โ Multiple payment methods
- โ Discount & promo
- โ Print receipt (Bluetooth thermal printer)
- โ Offline-first capability
- โ Real-time inventory sync via MQTT
๐ Struktur File
Copy
features/cashier/
โโโ bloc/
โ โโโ cashier_bloc.dart # Main cashier BLoC
โ โโโ cashier_event.dart # Events
โ โโโ cashier_state.dart # States
โโโ pages/
โ โโโ cashier_home_page.dart # Main cashier screen
โ โโโ cashier_payment_page.dart # Payment screen
โโโ widgets/
โ โโโ product_search_bar.dart
โ โโโ product_grid.dart
โ โโโ cart_widget.dart
โ โโโ cart_item_card.dart
โ โโโ payment_method_selector.dart
โ โโโ ...
โโโ models/
โโโ cart_item.dart
โโโ payment_method.dart
๐๏ธ Arsitektur
State Management
Menggunakan BLoC Pattern untuk manage state kasir.Copy
// Events
abstract class CashierEvent extends Equatable {}
class LoadProductsCashier extends CashierEvent {}
class AddToCartCashier extends CashierEvent {
final Product product;
final int quantity;
AddToCartCashier({required this.product, this.quantity = 1});
}
class UpdateCartItemQuantity extends CashierEvent {
final String productId;
final int quantity;
UpdateCartItemQuantity({required this.productId, required this.quantity});
}
class RemoveFromCartCashier extends CashierEvent {
final String productId;
RemoveFromCartCashier(this.productId);
}
class ClearCartCashier extends CashierEvent {}
class ApplyDiscountCashier extends CashierEvent {
final double discountPercent;
ApplyDiscountCashier(this.discountPercent);
}
class ToggleCartVisibility extends CashierEvent {}
Copy
// States
abstract class CashierState extends Equatable {}
class CashierInitial extends CashierState {}
class CashierLoading extends CashierState {}
class CashierLoaded extends CashierState {
final List<Product> products;
final List<CartItem> cartItems;
final bool isCartVisible;
final double subtotal;
final double discount;
final double total;
CashierLoaded({
required this.products,
required this.cartItems,
this.isCartVisible = false,
this.subtotal = 0,
this.discount = 0,
this.total = 0,
});
// Computed properties
int get totalItems => cartItems.fold(0, (sum, item) => sum + item.quantity);
bool get hasItems => cartItems.isNotEmpty;
}
class CashierError extends CashierState {
final String message;
CashierError(this.message);
}
BLoC Implementation
Copy
class CashierBloc extends Bloc<CashierEvent, CashierState> {
final ProductRepository _productRepository;
final ProductLocalRepository _productLocalRepository;
CashierBloc({
required ProductRepository productRepository,
required ProductLocalRepository productLocalRepository,
}) : _productRepository = productRepository,
_productLocalRepository = productLocalRepository,
super(CashierInitial()) {
on<LoadProductsCashier>(_onLoadProducts);
on<AddToCartCashier>(_onAddToCart);
on<UpdateCartItemQuantity>(_onUpdateQuantity);
on<RemoveFromCartCashier>(_onRemoveFromCart);
on<ClearCartCashier>(_onClearCart);
on<ApplyDiscountCashier>(_onApplyDiscount);
on<ToggleCartVisibility>(_onToggleCartVisibility);
}
Future<void> _onLoadProducts(
LoadProductsCashier event,
Emitter<CashierState> emit,
) async {
emit(CashierLoading());
// Try local first (offline-first)
final localProducts = await _productLocalRepository.getProducts();
if (localProducts.isNotEmpty) {
emit(CashierLoaded(
products: localProducts,
cartItems: [],
));
}
// Sync with API in background
final result = await _productRepository.getProducts();
result.fold(
(failure) {
if (localProducts.isEmpty) {
emit(CashierError(failure.message));
}
},
(products) async {
// Update local cache
await _productLocalRepository.saveProducts(products);
emit(CashierLoaded(
products: products,
cartItems: (state as CashierLoaded?)?.cartItems ?? [],
));
},
);
}
void _onAddToCart(
AddToCartCashier event,
Emitter<CashierState> emit,
) {
if (state is! CashierLoaded) return;
final currentState = state as CashierLoaded;
final cartItems = List<CartItem>.from(currentState.cartItems);
// Check if product already in cart
final existingIndex = cartItems.indexWhere(
(item) => item.product.id == event.product.id,
);
if (existingIndex >= 0) {
// Update quantity
cartItems[existingIndex] = cartItems[existingIndex].copyWith(
quantity: cartItems[existingIndex].quantity + event.quantity,
);
} else {
// Add new item
cartItems.add(CartItem(
product: event.product,
quantity: event.quantity,
));
}
emit(currentState.copyWith(
cartItems: cartItems,
isCartVisible: true,
)..recalculate());
}
void _onUpdateQuantity(
UpdateCartItemQuantity event,
Emitter<CashierState> emit,
) {
if (state is! CashierLoaded) return;
final currentState = state as CashierLoaded;
final cartItems = List<CartItem>.from(currentState.cartItems);
final index = cartItems.indexWhere(
(item) => item.product.id == event.productId,
);
if (index >= 0) {
if (event.quantity <= 0) {
cartItems.removeAt(index);
} else {
cartItems[index] = cartItems[index].copyWith(quantity: event.quantity);
}
}
emit(currentState.copyWith(cartItems: cartItems)..recalculate());
}
void _onClearCart(
ClearCartCashier event,
Emitter<CashierState> emit,
) {
if (state is! CashierLoaded) return;
final currentState = state as CashierLoaded;
emit(currentState.copyWith(
cartItems: [],
isCartVisible: false,
discount: 0,
)..recalculate());
}
}
๐ฅ๏ธ UI Components
Main Cashier Screen
Copy
class CashierHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => getIt<CashierBloc>()..add(LoadProductsCashier()),
child: Scaffold(
body: Row(
children: [
// Left: Product Grid
Expanded(
flex: 3,
child: Column(
children: [
ProductSearchBar(),
Expanded(child: ProductGrid()),
],
),
),
// Right: Cart
BlocBuilder<CashierBloc, CashierState>(
builder: (context, state) {
if (state is CashierLoaded && state.isCartVisible) {
return Expanded(
flex: 2,
child: CartWidget(state: state),
);
}
return SizedBox.shrink();
},
),
],
),
floatingActionButton: CartFloatingButton(),
),
);
}
}
Product Grid
Copy
class ProductGrid extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<CashierBloc, CashierState>(
builder: (context, state) {
if (state is CashierLoading) {
return Center(child: CircularProgressIndicator());
}
if (state is CashierError) {
return ErrorWidget(message: state.message);
}
if (state is CashierLoaded) {
return GridView.builder(
padding: EdgeInsets.all(16),
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
childAspectRatio: 0.75,
crossAxisSpacing: 16,
mainAxisSpacing: 16,
),
itemCount: state.products.length,
itemBuilder: (context, index) {
final product = state.products[index];
return ProductCard(
product: product,
onTap: () {
context.read<CashierBloc>().add(
AddToCartCashier(product: product),
);
},
);
},
);
}
return SizedBox.shrink();
},
);
}
}
Cart Widget
Copy
class CartWidget extends StatelessWidget {
final CashierLoaded state;
const CartWidget({required this.state});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black12,
blurRadius: 8,
offset: Offset(-2, 0),
),
],
),
child: Column(
children: [
// Header
CartHeader(itemCount: state.totalItems),
// Cart Items
Expanded(
child: ListView.builder(
itemCount: state.cartItems.length,
itemBuilder: (context, index) {
return CartItemCard(item: state.cartItems[index]);
},
),
),
// Summary
CartSummary(
subtotal: state.subtotal,
discount: state.discount,
total: state.total,
),
// Checkout Button
CheckoutButton(
enabled: state.hasItems,
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => CashierPaymentPage(
cartItems: state.cartItems,
total: state.total,
),
),
);
},
),
],
),
);
}
}
๐ณ Payment Flow
Payment Page
Copy
class CashierPaymentPage extends StatefulWidget {
final List<CartItem> cartItems;
final double total;
const CashierPaymentPage({
required this.cartItems,
required this.total,
});
@override
State<CashierPaymentPage> createState() => _CashierPaymentPageState();
}
class _CashierPaymentPageState extends State<CashierPaymentPage> {
PaymentMethod? selectedMethod;
double paidAmount = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Payment')),
body: Column(
children: [
// Order Summary
OrderSummaryCard(
items: widget.cartItems,
total: widget.total,
),
// Payment Method Selection
PaymentMethodSelector(
selected: selectedMethod,
onChanged: (method) {
setState(() => selectedMethod = method);
},
),
// Amount Input (for cash)
if (selectedMethod == PaymentMethod.cash)
CashAmountInput(
total: widget.total,
onChanged: (amount) {
setState(() => paidAmount = amount);
},
),
Spacer(),
// Process Payment Button
ProcessPaymentButton(
enabled: _canProcessPayment(),
onPressed: _processPayment,
),
],
),
);
}
bool _canProcessPayment() {
if (selectedMethod == null) return false;
if (selectedMethod == PaymentMethod.cash) {
return paidAmount >= widget.total;
}
return true;
}
Future<void> _processPayment() async {
final transaction = Transaction(
id: Uuid().v4(),
items: widget.cartItems,
subtotal: widget.total,
total: widget.total,
paymentMethod: selectedMethod!,
paidAmount: paidAmount,
change: paidAmount - widget.total,
timestamp: DateTime.now(),
);
// Create transaction via API
final result = await getIt<TransactionRepository>()
.createTransaction(transaction);
result.fold(
(failure) {
// Show error
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(failure.message)),
);
},
(createdTransaction) {
// Print receipt
_printReceipt(createdTransaction);
// Navigate back and clear cart
context.go('/cashier', extra: {'clearCart': true});
},
);
}
Future<void> _printReceipt(Transaction transaction) async {
final printerService = getIt<PrinterService>();
await printerService.printReceipt(transaction);
}
}
๐ Offline-First Strategy
Local Cache
Copy
class ProductLocalRepositoryImpl implements ProductLocalRepository {
final Isar isar;
@override
Future<List<Product>> getProducts() async {
final localProducts = await isar.productLocals
.where()
.sortByName()
.findAll();
return localProducts.map((p) => p.toDomain()).toList();
}
@override
Future<void> saveProducts(List<Product> products) async {
await isar.writeTxn(() async {
await isar.productLocals.clear();
await isar.productLocals.putAll(
products.map((p) => ProductLocal.fromDomain(p)).toList(),
);
});
}
}
MQTT Real-Time Sync
Copy
class CashierBloc extends Bloc<CashierEvent, CashierState> {
StreamSubscription? _mqttSubscription;
CashierBloc() : super(CashierInitial()) {
_subscribeMQTT();
}
void _subscribeMQTT() {
final mqttService = getIt<MqttService>();
final branchId = AuthService().getCurrentBranchId();
_mqttSubscription = mqttService
.subscribe('branch/$branchId/inventory')
.listen((message) {
// Update product stock real-time
final update = InventoryUpdate.fromJson(message);
add(UpdateProductStock(update));
});
}
@override
Future<void> close() {
_mqttSubscription?.cancel();
return super.close();
}
}
๐จ๏ธ Receipt Printing
Bluetooth Thermal Printer
Copy
class PrinterService {
Future<void> printReceipt(Transaction transaction) async {
final printer = await _getConnectedPrinter();
final receipt = ReceiptBuilder()
.addHeader('TOKO MUSHOLA')
.addDivider()
.addItems(transaction.items)
.addDivider()
.addSubtotal(transaction.subtotal)
.addDiscount(transaction.discount)
.addTotal(transaction.total)
.addPayment(transaction.paymentMethod, transaction.paidAmount)
.addChange(transaction.change)
.addFooter('Terima Kasih')
.build();
await printer.print(receipt);
}
}
๐จ UI/UX Features
Barcode Scanning
Copy
class ProductSearchBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: TextField(
decoration: InputDecoration(
hintText: 'Search product...',
prefixIcon: Icon(Icons.search),
),
onChanged: (query) {
context.read<CashierBloc>().add(SearchProducts(query));
},
),
),
IconButton(
icon: Icon(Icons.qr_code_scanner),
onPressed: () async {
final barcode = await Navigator.push(
context,
MaterialPageRoute(builder: (_) => BarcodeScannerPage()),
);
if (barcode != null) {
context.read<CashierBloc>().add(SearchByBarcode(barcode));
}
},
),
],
);
}
}
Keyboard Shortcuts
Copy
class CashierHomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return KeyboardListener(
focusNode: FocusNode(),
onKeyEvent: (event) {
if (event is KeyDownEvent) {
// F1: Toggle cart
if (event.logicalKey == LogicalKeyboardKey.f1) {
context.read<CashierBloc>().add(ToggleCartVisibility());
}
// F2: Checkout
if (event.logicalKey == LogicalKeyboardKey.f2) {
_navigateToPayment(context);
}
}
},
child: _buildBody(),
);
}
}
๐ Analytics & Reporting
Copy
// Track cashier performance
AppLog.i('cashier', 'transaction_completed', meta: {
'transaction_id': transaction.id,
'total': transaction.total,
'items_count': transaction.items.length,
'payment_method': transaction.paymentMethod.name,
'duration_seconds': transaction.duration.inSeconds,
});
๐งช Testing
Copy
void main() {
group('CashierBloc', () {
late CashierBloc bloc;
late MockProductRepository mockRepo;
setUp(() {
mockRepo = MockProductRepository();
bloc = CashierBloc(productRepository: mockRepo);
});
test('adds product to cart', () {
final product = Product(id: '1', name: 'Test', price: 10000);
bloc.add(AddToCartCashier(product: product));
expect(
bloc.stream,
emitsInOrder([
isA<CashierLoaded>()
.having((s) => s.cartItems.length, 'cart length', 1),
]),
);
});
});
}
Next Steps
- ๐ Payment Integration
- ๐จ๏ธ Printer Setup
- ๐ Transaction History
Best Practices:
- โ Offline-first untuk performa
- โ Real-time sync via MQTT
- โ Keyboard shortcuts untuk efisiensi
- โ Clear cart setelah checkout
- โ Print receipt otomatis