feat: split bill

This commit is contained in:
efrilm 2025-08-06 15:27:05 +07:00
parent 20f8d5788c
commit be803bbe4f
4 changed files with 813 additions and 5 deletions

View File

@ -68,9 +68,12 @@ class _PaymentPageState extends State<PaymentPage> {
'Pembayaran', 'Pembayaran',
style: TextStyle(color: AppColors.white), style: TextStyle(color: AppColors.white),
), ),
leading: Icon( leading: IconButton(
Icons.arrow_back, onPressed: () => context.pop(),
color: AppColors.white, icon: Icon(
Icons.arrow_back,
color: AppColors.white,
),
), ),
), ),
body: LayoutBuilder( body: LayoutBuilder(

View File

@ -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/home/bloc/order_form/order_form_bloc.dart';
import 'package:enaklo_pos/presentation/payment/pages/payment_page.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/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/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/void/pages/void_page.dart';
import 'package:enaklo_pos/presentation/sales/widgets/sales_detail.dart'; import 'package:enaklo_pos/presentation/sales/widgets/sales_detail.dart';
import 'package:enaklo_pos/presentation/sales/widgets/sales_list_order.dart'; import 'package:enaklo_pos/presentation/sales/widgets/sales_list_order.dart';
@ -213,6 +213,18 @@ class _SalesPageState extends State<SalesPage> {
label: 'Bayar', label: 'Bayar',
icon: Icon(Icons.payment), 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') if (widget.status == 'completed')
Button.outlined( Button.outlined(

View File

@ -27,7 +27,7 @@ class SalesRightTitle extends StatelessWidget {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
"Detail Pesanan #${order?.orderNumber}", "Detail Pesanan",
style: TextStyle( style: TextStyle(
color: AppColors.black, color: AppColors.black,
fontSize: 20, fontSize: 20,

View File

@ -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<SplitBillPage> createState() => _SplitBillPageState();
}
class _SplitBillPageState extends State<SplitBillPage> {
int selectedSplitType = 0; // 0 = Per Product, 1 = Per Amount
// Per Product Split Data
Map<String, int> selectedProducts = {}; // {itemId: quantity}
// Per Amount Split Data
TextEditingController amountController = TextEditingController();
int splitAmount = 0;
List<OrderItem> 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<OrderItem> 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");
}
}
}
}