Skip to main content

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


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