From be803bbe4f661fa859326ea7735a511b2a01ed63 Mon Sep 17 00:00:00 2001 From: efrilm Date: Wed, 6 Aug 2025 15:27:05 +0700 Subject: [PATCH] feat: split bill --- .../payment/pages/payment_page.dart | 9 +- lib/presentation/sales/pages/sales_page.dart | 14 +- .../sales/widgets/sales_right_title.dart | 2 +- .../split_bill/pages/split_bill_page.dart | 793 ++++++++++++++++++ 4 files changed, 813 insertions(+), 5 deletions(-) create mode 100644 lib/presentation/split_bill/pages/split_bill_page.dart diff --git a/lib/presentation/payment/pages/payment_page.dart b/lib/presentation/payment/pages/payment_page.dart index 4f5922f..7725631 100644 --- a/lib/presentation/payment/pages/payment_page.dart +++ b/lib/presentation/payment/pages/payment_page.dart @@ -68,9 +68,12 @@ class _PaymentPageState extends State { 'Pembayaran', style: TextStyle(color: AppColors.white), ), - leading: Icon( - Icons.arrow_back, - color: AppColors.white, + leading: IconButton( + onPressed: () => context.pop(), + icon: Icon( + Icons.arrow_back, + color: AppColors.white, + ), ), ), body: LayoutBuilder( diff --git a/lib/presentation/sales/pages/sales_page.dart b/lib/presentation/sales/pages/sales_page.dart index 6d95e36..56c1eb0 100644 --- a/lib/presentation/sales/pages/sales_page.dart +++ b/lib/presentation/sales/pages/sales_page.dart @@ -5,8 +5,8 @@ import 'package:enaklo_pos/data/models/response/order_response_model.dart'; import 'package:enaklo_pos/presentation/home/bloc/order_form/order_form_bloc.dart'; import 'package:enaklo_pos/presentation/payment/pages/payment_page.dart'; import 'package:enaklo_pos/presentation/refund/pages/refund_page.dart'; -import 'package:enaklo_pos/presentation/sales/blocs/day_sales/day_sales_bloc.dart'; import 'package:enaklo_pos/presentation/sales/blocs/order_loader/order_loader_bloc.dart'; +import 'package:enaklo_pos/presentation/split_bill/pages/split_bill_page.dart'; import 'package:enaklo_pos/presentation/void/pages/void_page.dart'; import 'package:enaklo_pos/presentation/sales/widgets/sales_detail.dart'; import 'package:enaklo_pos/presentation/sales/widgets/sales_list_order.dart'; @@ -213,6 +213,18 @@ class _SalesPageState extends State { label: 'Bayar', icon: Icon(Icons.payment), ), + SpaceWidth(8), + Button.outlined( + onPressed: () { + context.push( + SplitBillPage( + order: orderDetail!, + ), + ); + }, + label: 'Split Bill', + icon: Icon(Icons.payment), + ), ], if (widget.status == 'completed') Button.outlined( diff --git a/lib/presentation/sales/widgets/sales_right_title.dart b/lib/presentation/sales/widgets/sales_right_title.dart index 19ecd9a..dc9c943 100644 --- a/lib/presentation/sales/widgets/sales_right_title.dart +++ b/lib/presentation/sales/widgets/sales_right_title.dart @@ -27,7 +27,7 @@ class SalesRightTitle extends StatelessWidget { children: [ Expanded( child: Text( - "Detail Pesanan #${order?.orderNumber}", + "Detail Pesanan", style: TextStyle( color: AppColors.black, fontSize: 20, diff --git a/lib/presentation/split_bill/pages/split_bill_page.dart b/lib/presentation/split_bill/pages/split_bill_page.dart new file mode 100644 index 0000000..334ad63 --- /dev/null +++ b/lib/presentation/split_bill/pages/split_bill_page.dart @@ -0,0 +1,793 @@ +import 'dart:developer'; + +import 'package:enaklo_pos/core/components/flushbar.dart'; +import 'package:enaklo_pos/core/extensions/build_context_ext.dart'; +import 'package:enaklo_pos/data/models/response/order_response_model.dart'; +import 'package:enaklo_pos/presentation/payment/pages/payment_page.dart'; +import 'package:flutter/material.dart'; + +class AppColorSplitBill { + static const Color primary = Color(0xff36175e); + static const Color background = Color(0xfff8f9fa); + static const Color cardBackground = Colors.white; + static const Color textPrimary = Color(0xff2d3436); + static const Color textSecondary = Color(0xff636e72); + static const Color success = Color(0xff00b894); + static const Color border = Color(0xffddd); +} + +class SplitBillPage extends StatefulWidget { + final Order order; + + const SplitBillPage({super.key, required this.order}); + + @override + State createState() => _SplitBillPageState(); +} + +class _SplitBillPageState extends State { + int selectedSplitType = 0; // 0 = Per Product, 1 = Per Amount + + // Per Product Split Data + Map selectedProducts = {}; // {itemId: quantity} + + // Per Amount Split Data + TextEditingController amountController = TextEditingController(); + int splitAmount = 0; + + List getOrderItemPending() => + widget.order.orderItems + ?.where((item) => item.status == "pending") + .toList() ?? + []; + + @override + void dispose() { + amountController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColorSplitBill.background, + body: Row( + children: [ + // Left Panel - Bill Details + Expanded( + flex: 2, + child: Container( + color: AppColorSplitBill.cardBackground, + padding: EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Ringkasan Pesanan', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppColorSplitBill.textPrimary, + ), + ), + SizedBox(height: 8), + Text( + 'Order #${widget.order.orderNumber}', + style: TextStyle( + fontSize: 16, + color: AppColorSplitBill.textSecondary, + ), + ), + SizedBox(height: 24), + Expanded( + child: ListView.builder( + itemCount: getOrderItemPending().length, + itemBuilder: (context, index) { + return _buildOrderItem(getOrderItemPending()[index]); + }, + ), + ), + Container( + width: double.infinity, + height: 1, + color: AppColorSplitBill.border, + margin: EdgeInsets.symmetric(vertical: 16), + ), + if (widget.order.subtotal != widget.order.totalAmount) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Subtotal', + style: TextStyle( + fontSize: 16, + color: AppColorSplitBill.textSecondary, + ), + ), + Text( + 'Rp ${_formatCurrency(widget.order.subtotal ?? 0)}', + style: TextStyle( + fontSize: 16, + color: AppColorSplitBill.textSecondary, + ), + ), + ], + ), + if ((widget.order.taxAmount ?? 0) > 0) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Pajak', + style: TextStyle( + fontSize: 16, + color: AppColorSplitBill.textSecondary, + ), + ), + Text( + 'Rp ${_formatCurrency(widget.order.taxAmount ?? 0)}', + style: TextStyle( + fontSize: 16, + color: AppColorSplitBill.textSecondary, + ), + ), + ], + ), + if ((widget.order.discountAmount ?? 0) > 0) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Diskon', + style: TextStyle( + fontSize: 16, + color: AppColorSplitBill.success, + ), + ), + Text( + '- Rp ${_formatCurrency(widget.order.discountAmount ?? 0)}', + style: TextStyle( + fontSize: 16, + color: AppColorSplitBill.success, + ), + ), + ], + ), + SizedBox(height: 8), + ], + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Total Pembayaran', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColorSplitBill.textPrimary, + ), + ), + Text( + 'Rp ${_formatCurrency(widget.order.totalAmount ?? 0)}', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: AppColorSplitBill.primary, + ), + ), + ], + ), + ], + ), + ), + ), + + // Right Panel - Split Options + Expanded( + flex: 3, + child: Container( + padding: EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Bagi Bill', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppColorSplitBill.textPrimary, + ), + ), + SizedBox(height: 24), + + // Split Type Selection + Row( + children: [ + _buildSplitTypeButton('Per Produk', 0), + SizedBox(width: 16), + _buildSplitTypeButton('Per Jumlah', 1), + ], + ), + + SizedBox(height: 32), + + // Split Content + Expanded( + child: selectedSplitType == 0 + ? _buildPerProductSplit() + : _buildPerAmountSplit(), + ), + + SizedBox(height: 24), + + // Action Buttons + Row( + children: [ + Expanded( + child: _buildActionButton( + 'Batal', + AppColorSplitBill.textSecondary, + Colors.transparent, + () => Navigator.pop(context), + ), + ), + SizedBox(width: 16), + Expanded( + child: _buildActionButton( + 'Konfirmasi Split', + Colors.white, + AppColorSplitBill.primary, + () { + _confirmSplit(); + }, + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _buildOrderItem(OrderItem item) { + return Container( + padding: EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.productName ?? '', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColorSplitBill.textPrimary, + ), + ), + if (item.productVariantName != null && + item.productVariantName!.isNotEmpty) + Text( + item.productVariantName!, + style: TextStyle( + fontSize: 14, + color: AppColorSplitBill.textSecondary, + fontStyle: FontStyle.italic, + ), + ), + Text( + 'Qty: ${item.quantity} x Rp ${_formatCurrency(item.unitPrice ?? 0)}', + style: TextStyle( + fontSize: 14, + color: AppColorSplitBill.textSecondary, + ), + ), + ], + ), + ), + Text( + 'Rp ${_formatCurrency(item.totalPrice ?? 0)}', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColorSplitBill.textPrimary, + ), + ), + ], + ), + ); + } + + Widget _buildSplitTypeButton(String title, int type) { + bool isSelected = selectedSplitType == type; + return GestureDetector( + onTap: () { + setState(() { + selectedSplitType = type; + }); + }, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 12), + decoration: BoxDecoration( + color: isSelected ? AppColorSplitBill.primary : Colors.transparent, + border: Border.all( + color: isSelected + ? AppColorSplitBill.primary + : AppColorSplitBill.border, + width: 2, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + title, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: isSelected ? Colors.white : AppColorSplitBill.textPrimary, + ), + ), + ), + ); + } + + Widget _buildPerProductSplit() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Pilih Produk untuk Split', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColorSplitBill.textPrimary, + ), + ), + SizedBox(height: 16), + Expanded( + child: Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColorSplitBill.cardBackground, + border: Border.all(color: AppColorSplitBill.border), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Split Bill', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColorSplitBill.primary, + ), + ), + SizedBox(height: 16), + Expanded( + child: ListView.builder( + itemCount: getOrderItemPending().length, + itemBuilder: (context, index) { + return _buildProductSplitItem( + getOrderItemPending()[index]); + }, + ), + ), + Container( + width: double.infinity, + height: 1, + color: AppColorSplitBill.border, + margin: EdgeInsets.symmetric(vertical: 12), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Total Split:', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColorSplitBill.textPrimary, + ), + ), + Text( + 'Rp ${_formatCurrency(_calculateSplitTotal())}', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColorSplitBill.primary, + ), + ), + ], + ), + ], + ), + ), + ), + ], + ); + } + + Widget _buildProductSplitItem(OrderItem item) { + int selectedQty = selectedProducts[item.id] ?? 0; + int maxQty = item.quantity ?? 0; + + return Container( + margin: EdgeInsets.only(bottom: 12), + padding: EdgeInsets.all(12), + decoration: BoxDecoration( + color: selectedQty > 0 + ? AppColorSplitBill.primary.withOpacity(0.1) + : Colors.transparent, + border: Border.all( + color: selectedQty > 0 + ? AppColorSplitBill.primary + : AppColorSplitBill.border, + ), + borderRadius: BorderRadius.circular(6), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.productName ?? '', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColorSplitBill.textPrimary, + ), + ), + if (item.productVariantName != null && + item.productVariantName!.isNotEmpty) + Text( + item.productVariantName!, + style: TextStyle( + fontSize: 12, + color: AppColorSplitBill.textSecondary, + fontStyle: FontStyle.italic, + ), + ), + Text( + 'Rp ${_formatCurrency(item.unitPrice ?? 0)} per item', + style: TextStyle( + fontSize: 12, + color: AppColorSplitBill.textSecondary, + ), + ), + ], + ), + ), + Row( + children: [ + GestureDetector( + onTap: () { + if (selectedQty > 0) { + setState(() { + selectedProducts[item.id!] = selectedQty - 1; + if (selectedProducts[item.id!] == 0) { + selectedProducts.remove(item.id); + } + }); + } + }, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: selectedQty > 0 + ? AppColorSplitBill.primary + : AppColorSplitBill.border, + borderRadius: BorderRadius.circular(4), + ), + child: Icon( + Icons.remove, + size: 16, + color: selectedQty > 0 + ? Colors.white + : AppColorSplitBill.textSecondary, + ), + ), + ), + Container( + width: 40, + height: 32, + margin: EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + border: Border.all(color: AppColorSplitBill.border), + borderRadius: BorderRadius.circular(4), + ), + child: Center( + child: Text( + '$selectedQty', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + color: AppColorSplitBill.textPrimary, + ), + ), + ), + ), + GestureDetector( + onTap: () { + if (selectedQty < maxQty) { + setState(() { + selectedProducts[item.id!] = selectedQty + 1; + }); + } + }, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: selectedQty < maxQty + ? AppColorSplitBill.primary + : AppColorSplitBill.border, + borderRadius: BorderRadius.circular(4), + ), + child: Icon( + Icons.add, + size: 16, + color: selectedQty < maxQty + ? Colors.white + : AppColorSplitBill.textSecondary, + ), + ), + ), + ], + ), + ], + ), + ); + } + + Widget _buildPerAmountSplit() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Masukkan Jumlah yang Akan Dibayar', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: AppColorSplitBill.textPrimary, + ), + ), + SizedBox(height: 16), + Expanded( + child: Column( + children: [ + Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColorSplitBill.cardBackground, + border: Border.all(color: AppColorSplitBill.border), + borderRadius: BorderRadius.circular(8), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Split Bill', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.bold, + color: AppColorSplitBill.primary, + ), + ), + SizedBox(height: 16), + Text( + 'Jumlah yang akan dibayar:', + style: TextStyle( + fontSize: 14, + color: AppColorSplitBill.textSecondary, + ), + ), + SizedBox(height: 8), + Container( + decoration: BoxDecoration( + border: Border.all(color: AppColorSplitBill.border), + borderRadius: BorderRadius.circular(8), + ), + child: TextField( + controller: amountController, + keyboardType: TextInputType.number, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColorSplitBill.textPrimary, + ), + onChanged: (value) { + setState(() { + splitAmount = int.tryParse(value) ?? 0; + }); + }, + decoration: InputDecoration( + hintText: '0', + prefixText: 'Rp ', + prefixStyle: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColorSplitBill.textPrimary, + ), + border: InputBorder.none, + contentPadding: EdgeInsets.all(16), + ), + ), + ), + SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Total yang dibayar:', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColorSplitBill.textPrimary, + ), + ), + Text( + 'Rp ${_formatCurrency(splitAmount)}', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColorSplitBill.primary, + ), + ), + ], + ), + SizedBox(height: 8), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Sisa bill:', + style: TextStyle( + fontSize: 14, + color: AppColorSplitBill.textSecondary, + ), + ), + Text( + 'Rp ${_formatCurrency((widget.order.totalAmount ?? 0) - splitAmount)}', + style: TextStyle( + fontSize: 14, + color: AppColorSplitBill.textSecondary, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ], + ); + } + + Widget _buildActionButton( + String text, Color textColor, Color bgColor, VoidCallback onTap) { + return GestureDetector( + onTap: onTap, + child: Container( + height: 50, + decoration: BoxDecoration( + color: bgColor, + border: bgColor == Colors.transparent + ? Border.all(color: AppColorSplitBill.border) + : null, + borderRadius: BorderRadius.circular(8), + ), + child: Center( + child: Text( + text, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: textColor, + ), + ), + ), + ), + ); + } + + int _calculateSplitTotal() { + int total = 0; + selectedProducts.forEach((itemId, quantity) { + OrderItem? item = getOrderItemPending() + .firstWhere((item) => item.id == itemId, orElse: () => OrderItem()); + if (item.unitPrice != null) { + total += (item.unitPrice! * quantity); + } + }); + return total; + } + + String _formatCurrency(int amount) { + return amount.toString().replaceAllMapped( + RegExp(r'(\d)(?=(\d{3})+(?!\d))'), + (Match m) => '${m[1]}.', + ); + } + + void _confirmSplit() { + if (selectedSplitType == 0) { + // Per Product Split + int splitTotal = _calculateSplitTotal(); + if (splitTotal > 0) { + // Create a new Order object with only the selected products for split + List splitItems = []; + selectedProducts.forEach((itemId, quantity) { + OrderItem? originalItem = getOrderItemPending().firstWhere( + (item) => item.id == itemId, + orElse: () => OrderItem()); + // ignore: unnecessary_null_comparison + if (originalItem != null && originalItem.id != null) { + // Create a copy of the item with the selected quantity + OrderItem splitItem = OrderItem( + id: originalItem.id, + productName: originalItem.productName, + productVariantName: originalItem.productVariantName, + quantity: quantity, + unitPrice: originalItem.unitPrice, + totalPrice: (originalItem.unitPrice ?? 0) * quantity, + status: originalItem.status, + ); + splitItems.add(splitItem); + } + }); + + // Create split order object + Order splitOrder = Order( + id: widget.order.id, + orderNumber: widget.order.orderNumber, + orderItems: splitItems, + subtotal: splitTotal, + totalAmount: splitTotal, + taxAmount: 0, // You might want to calculate proportional tax + discountAmount: + 0, // You might want to calculate proportional discount + ); + + log("Split Order: ${splitItems.length}"); + + // Navigate to PaymentPage with the split order + context.push(PaymentPage(order: splitOrder)); + } else { + AppFlushbar.showError(context, "Pilih minimal satu produk untuk split"); + } + } else { + // Per Amount Split + int totalAmount = widget.order.totalAmount ?? 0; + + if (splitAmount > 0 && splitAmount <= totalAmount) { + // Create split order object with the specified amount + Order splitOrder = Order( + id: widget.order.id, + orderNumber: widget.order.orderNumber, + orderItems: getOrderItemPending(), // Keep all items for reference + subtotal: splitAmount, + totalAmount: splitAmount, + taxAmount: 0, // You might want to calculate proportional values + discountAmount: 0, + ); + + // Navigate to PaymentPage with the split order + context.push(PaymentPage(order: splitOrder)); + } else if (splitAmount > totalAmount) { + AppFlushbar.showError( + context, "Jumlah split tidak boleh melebihi total bill"); + } else { + AppFlushbar.showError(context, "Pilih minimal satu produk untuk split"); + } + } + } +}