Documentation Index
Fetch the complete documentation index at: https://docs-mstore.faisalaffan.com/llms.txt
Use this file to discover all available pages before exploring further.
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
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.// 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 {}
// 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
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
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
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
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
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
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
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
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
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
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
// 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
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