From a436769ec17ab7f0f3ac44b3a798eba61deaed4d Mon Sep 17 00:00:00 2001 From: efrilm Date: Tue, 5 Aug 2025 10:26:25 +0700 Subject: [PATCH] feat: payment page --- lib/core/constants/colors.dart | 4 + .../payment/pages/payment_page.dart | 444 ++++++++++++++++++ lib/presentation/sales/pages/sales_page.dart | 15 +- 3 files changed, 456 insertions(+), 7 deletions(-) create mode 100644 lib/presentation/payment/pages/payment_page.dart diff --git a/lib/core/constants/colors.dart b/lib/core/constants/colors.dart index 1c83980..8c0311b 100644 --- a/lib/core/constants/colors.dart +++ b/lib/core/constants/colors.dart @@ -39,4 +39,8 @@ class AppColors { static const Color stroke = Color(0xffEFF0F6); static const Color background = Color.fromARGB(255, 241, 241, 241); + + static const Color primaryLight = Color(0xFF5A3E8A); + static const Color greyLight = Color(0xFFE0E0E0); + static const Color greyDark = Color(0xFF707070); } diff --git a/lib/presentation/payment/pages/payment_page.dart b/lib/presentation/payment/pages/payment_page.dart new file mode 100644 index 0000000..393bc33 --- /dev/null +++ b/lib/presentation/payment/pages/payment_page.dart @@ -0,0 +1,444 @@ +import 'package:enaklo_pos/core/components/buttons.dart'; +import 'package:enaklo_pos/core/components/flushbar.dart'; +import 'package:enaklo_pos/core/constants/colors.dart'; +import 'package:enaklo_pos/core/extensions/build_context_ext.dart'; +import 'package:enaklo_pos/core/extensions/int_ext.dart'; +import 'package:enaklo_pos/core/extensions/string_ext.dart'; +import 'package:enaklo_pos/data/models/request/payment_request.dart'; +import 'package:enaklo_pos/data/models/response/order_response_model.dart'; +import 'package:enaklo_pos/data/models/response/payment_methods_response_model.dart'; +import 'package:enaklo_pos/data/models/response/product_response_model.dart'; +import 'package:enaklo_pos/presentation/home/bloc/payment_methods/payment_methods_bloc.dart'; +import 'package:enaklo_pos/presentation/home/models/product_quantity.dart'; +import 'package:enaklo_pos/presentation/sales/blocs/payment_form/payment_form_bloc.dart'; +import 'package:enaklo_pos/presentation/success/pages/success_payment_page.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class PaymentPage extends StatefulWidget { + final Order order; + const PaymentPage({Key? key, required this.order}) : super(key: key); + + @override + State createState() => _PaymentPageState(); +} + +class _PaymentPageState extends State { + PaymentMethod? selectedPaymentMethod; + final totalPriceController = TextEditingController(); + int priceValue = 0; + late int uangPas; + final int uangPas2 = 50000; + final int uangPas3 = 100000; + + @override + void initState() { + super.initState(); + uangPas = widget.order.totalAmount ?? 0; + totalPriceController.text = uangPas.currencyFormatRpV2; + priceValue = uangPas; + + context + .read() + .add(PaymentMethodsEvent.fetchPaymentMethods()); + } + + @override + void dispose() { + totalPriceController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final totalAmount = widget.order.totalAmount ?? 0; + + return Scaffold( + backgroundColor: AppColors.background, + appBar: AppBar( + backgroundColor: AppColors.primary, + title: const Text( + 'Pembayaran', + style: TextStyle(color: AppColors.white), + ), + leading: Icon( + Icons.arrow_back, + color: AppColors.white, + ), + ), + body: LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth > 800; + return Padding( + padding: const EdgeInsets.all(16), + child: isWide + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded(flex: 3, child: _buildOrderDetail()), + const SizedBox(width: 24), + Expanded(flex: 2, child: _buildPaymentForm(totalAmount)), + ], + ) + : SingleChildScrollView( + child: Column( + children: [ + _buildOrderDetail(), + const SizedBox(height: 24), + _buildPaymentForm(totalAmount), + ], + ), + ), + ); + }, + ), + ); + } + + Widget _buildOrderDetail() { + final order = widget.order; + final items = order.orderItems ?? []; + + return Card( + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + color: AppColors.white, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Detail Order', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppColors.primary)), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text('No. Pesanan', + style: TextStyle(fontWeight: FontWeight.w600)), + Text(order.orderNumber ?? '-', + style: const TextStyle(fontWeight: FontWeight.bold)), + ], + ), + const SizedBox(height: 8), + Divider(color: AppColors.grey), + const SizedBox(height: 8), + Expanded( + child: items.isEmpty + ? const Center(child: Text('Tidak ada item')) + : ListView.separated( + shrinkWrap: true, + itemCount: items.length, + separatorBuilder: (_, __) => + Divider(color: AppColors.grey.withOpacity(0.5)), + itemBuilder: (context, index) { + final item = items[index]; + return ListTile( + contentPadding: EdgeInsets.zero, + title: Text(item.productName ?? '-'), + subtitle: Text( + 'Qty: ${item.quantity ?? 0} | ${item.productVariantName ?? ''}'), + trailing: Text( + (item.totalPrice ?? 0).currencyFormatRpV2, + style: + const TextStyle(fontWeight: FontWeight.bold)), + ); + }, + ), + ), + Divider(color: AppColors.grey), + _buildSummaryRow('Subtotal', order.subtotal ?? 0), + _buildSummaryRow('Pajak', order.taxAmount ?? 0), + _buildSummaryRow('Diskon', order.discountAmount ?? 0), + Divider(thickness: 2, color: AppColors.primary), + _buildSummaryRow('Total', order.totalAmount ?? 0, + isTotal: true, color: AppColors.primary), + ], + ), + ), + ); + } + + Widget _buildSummaryRow(String label, int amount, + {bool isTotal = false, Color? color}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, + style: TextStyle( + fontWeight: isTotal ? FontWeight.bold : FontWeight.normal, + fontSize: isTotal ? 18 : 14, + color: color ?? Colors.black)), + Text(amount.currencyFormatRpV2, + style: TextStyle( + fontWeight: isTotal ? FontWeight.bold : FontWeight.normal, + fontSize: isTotal ? 18 : 14, + color: color ?? Colors.black)), + ], + ), + ); + } + + Widget _buildPaymentForm(int totalAmount) { + return Card( + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + color: AppColors.white, + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Pembayaran', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: AppColors.primary)), + Expanded( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 24), + const Text('Metode Pembayaran', + style: TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + BlocBuilder( + builder: (context, state) { + return state.maybeWhen( + orElse: () => + const Center(child: CircularProgressIndicator()), + loading: () => const Center( + child: Column( + children: [ + CircularProgressIndicator(), + SizedBox(height: 8), + Text('Loading metode pembayaran...'), + ], + ), + ), + error: (message) => Column( + children: [ + Text('Gagal memuat metode pembayaran: $message'), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + context.read().add( + PaymentMethodsEvent + .fetchPaymentMethods()); + }, + child: const Text('Coba Lagi'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + ), + ), + ], + ), + loaded: (methods) { + if (methods.isEmpty) { + return Column( + children: [ + const Text( + 'Tidak ada metode pembayaran tersedia'), + const SizedBox(height: 16), + ElevatedButton( + onPressed: () { + context.read().add( + PaymentMethodsEvent + .fetchPaymentMethods()); + }, + child: const Text('Coba Lagi'), + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primary, + ), + ), + ], + ); + } + return Wrap( + spacing: 12, + runSpacing: 12, + children: methods.map((method) { + final isSelected = + selectedPaymentMethod?.id == method.id; + return ChoiceChip( + label: Text(method.name ?? ''), + selected: isSelected, + onSelected: (_) { + setState(() { + selectedPaymentMethod = method; + if (method.type != "cash") { + totalPriceController.text = + totalAmount.currencyFormatRpV2; + priceValue = totalAmount; + } + }); + }, + selectedColor: AppColors.primary, + backgroundColor: AppColors.white, + labelStyle: TextStyle( + color: isSelected + ? AppColors.white + : AppColors.primary, + fontWeight: FontWeight.bold, + ), + shape: RoundedRectangleBorder( + side: BorderSide(color: AppColors.primary), + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 12), + ); + }).toList(), + ); + }, + ); + }, + ), + const SizedBox(height: 24), + if (selectedPaymentMethod?.type == "cash") ...[ + const Text('Total Bayar', + style: TextStyle(fontWeight: FontWeight.w600)), + const SizedBox(height: 8), + TextFormField( + controller: totalPriceController, + keyboardType: TextInputType.number, + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8)), + hintText: 'Masukkan nominal bayar', + ), + onChanged: (value) { + final newValue = value.toIntegerFromText; + setState(() { + priceValue = newValue; + totalPriceController.text = + newValue.currencyFormatRp; + totalPriceController.selection = + TextSelection.fromPosition(TextPosition( + offset: totalPriceController.text.length)); + }); + }, + ), + const SizedBox(height: 20), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + _quickAmountButton('UANG PAS', uangPas), + const SizedBox(width: 16), + _quickAmountButton( + uangPas2.currencyFormatRpV2, uangPas2), + const SizedBox(width: 16), + _quickAmountButton( + uangPas3.currencyFormatRpV2, uangPas3), + ], + ), + ), + ], + const SizedBox(height: 32), + ], + ), + ), + ), + BlocListener( + listener: (context, state) { + state.maybeWhen( + orElse: () {}, + success: (data) { + context.pushReplacement(SuccessPaymentPage( + productQuantity: widget.order.orderItems + ?.map( + (item) => ProductQuantity( + product: Product( + name: item.productName, + price: item.unitPrice, + ), + quantity: item.quantity ?? 0, + ), + ) + .toList() ?? + [], + payment: data, + )); + }, + error: (message) { + AppFlushbar.showError(context, message); + }, + ); + }, + child: BlocBuilder( + builder: (context, state) { + return state.maybeWhen( + orElse: () => + Button.filled(onPressed: _onPayPressed, label: "Bayar"), + loading: () => + const Center(child: CircularProgressIndicator()), + ); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _quickAmountButton(String label, int amount) { + return OutlinedButton( + onPressed: () { + setState(() { + totalPriceController.text = amount.currencyFormatRpV2; + priceValue = amount; + }); + }, + style: OutlinedButton.styleFrom( + side: BorderSide(color: AppColors.primary), + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), + ), + child: Text(label, + style: + TextStyle(color: AppColors.primary, fontWeight: FontWeight.bold)), + ); + } + + void _onPayPressed() { + if (selectedPaymentMethod == null) { + AppFlushbar.showError(context, 'Pilih metode pembayaran terlebih dahulu'); + return; + } + + if (selectedPaymentMethod?.type == "cash" && priceValue == 0) { + AppFlushbar.showError(context, 'Total bayar tidak boleh 0'); + return; + } + + final itemPending = widget.order.orderItems + ?.where((item) => item.status == "pending") + .toList(); + + final request = PaymentRequestModel( + amount: widget.order.totalAmount ?? 0, + orderId: widget.order.id, + paymentMethodId: selectedPaymentMethod?.id, + splitDescription: '', + splitNumber: 1, + splitTotal: 1, + transactionId: '', + paymentOrderItems: itemPending + ?.map((item) => PaymentOrderItemModel( + orderItemId: item.id, + amount: item.totalPrice, + )) + .toList(), + ); + + context.read().add(PaymentFormEvent.create(request)); + } +} diff --git a/lib/presentation/sales/pages/sales_page.dart b/lib/presentation/sales/pages/sales_page.dart index 86935eb..5d65b0c 100644 --- a/lib/presentation/sales/pages/sales_page.dart +++ b/lib/presentation/sales/pages/sales_page.dart @@ -3,10 +3,10 @@ import 'package:enaklo_pos/core/components/spaces.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/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/sales/dialog/payment_dialog.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'; @@ -202,12 +202,13 @@ class _SalesPageState extends State { }), SpaceWidth(8), Button.outlined( - onPressed: () => showDialog( - context: context, - builder: (context) => PaymentDialog( - order: orderDetail!, - ), - ), + onPressed: () { + context.push( + PaymentPage( + order: orderDetail!, + ), + ); + }, label: 'Bayar', icon: Icon(Icons.payment), ),