Skip to main content

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


Best Practices:
  • โœ… Offline-first untuk performa
  • โœ… Real-time sync via MQTT
  • โœ… Keyboard shortcuts untuk efisiensi
  • โœ… Clear cart setelah checkout
  • โœ… Print receipt otomatis