Compare commits

...

2 Commits

Author SHA1 Message Date
efrilm
0693411cf7 confirm order save 2025-10-28 17:10:47 +07:00
efrilm
a514a8abdb save order pay later page 2025-10-28 16:26:35 +07:00
7 changed files with 699 additions and 47 deletions

View File

@ -41,7 +41,12 @@ class OrderFormBloc extends Bloc<OrderFormEvent, OrderFormState> {
createOrderWithPayment: (e) async {
Either<OrderFailure, Order> failureOrOrder;
emit(state.copyWith(isCreating: true, failureOrCreateOrder: none()));
emit(
state.copyWith(
isCreatingWithPayment: true,
failureOrCreateOrderWithPayment: none(),
),
);
final outlet = await _outletRepository.currentOutlet();
@ -73,8 +78,8 @@ class OrderFormBloc extends Bloc<OrderFormEvent, OrderFormState> {
emit(
state.copyWith(
isCreating: false,
failureOrCreateOrder: optionOf(failureOrOrder),
isCreatingWithPayment: false,
failureOrCreateOrderWithPayment: optionOf(failureOrOrder),
),
);
},

View File

@ -4,6 +4,7 @@ class CustomModalDialog extends StatelessWidget {
final String title;
final String? subtitle;
final Widget child;
final Widget? bottom;
final VoidCallback? onClose;
final double? minWidth;
final double? maxWidth;
@ -22,6 +23,7 @@ class CustomModalDialog extends StatelessWidget {
this.minHeight,
this.maxHeight,
this.contentPadding,
this.bottom,
});
@override
@ -96,6 +98,7 @@ class CustomModalDialog extends StatelessWidget {
child: child,
),
),
if (bottom != null) bottom!,
],
),
),

View File

@ -0,0 +1,260 @@
import 'package:dropdown_search/dropdown_search.dart';
import 'package:flutter/material.dart' hide Table;
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../application/checkout/checkout_form/checkout_form_bloc.dart';
import '../../../../application/order/order_form/order_form_bloc.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../../domain/table/table.dart';
import '../../button/button.dart';
import '../../spaces/space.dart';
import '../dialog.dart';
class OrderPayLaterDialog extends StatelessWidget {
final List<Table> tables;
const OrderPayLaterDialog({super.key, required this.tables});
@override
Widget build(BuildContext context) {
return BlocBuilder<CheckoutFormBloc, CheckoutFormState>(
builder: (context, state) {
return CustomModalDialog(
title: 'Bayar Nanti',
subtitle: 'Simpan pesanan dan bayar nanti',
contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 24.0,
),
minWidth: context.deviceWidth * 0.4,
minHeight: context.deviceHeight * 0.4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Pilih Meja',
style: AppStyle.md.copyWith(
color: AppColor.black,
fontWeight: FontWeight.w600,
),
),
const SpaceHeight(6.0),
Builder(
builder: (context) {
if (tables.isEmpty) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColor.warning.withOpacity(0.15),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColor.warning, width: 1),
),
child: Text(
'Tidak ada meja yang tersedia. Silakan pilih opsi lain.',
style: AppStyle.md.copyWith(color: AppColor.warning),
),
);
}
return DropdownSearch<Table>(
items: tables,
selectedItem: state.table,
// Dropdown properties
dropdownDecoratorProps: DropDownDecoratorProps(
dropdownSearchDecoration: InputDecoration(
hintText: "Pilih meja",
hintStyle: TextStyle(
color: Colors.grey.shade600,
fontSize: 14,
),
prefixIcon: Icon(
Icons.category_outlined,
color: Colors.grey.shade500,
size: 20,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.grey.shade300,
width: 1.5,
),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.grey.shade300,
width: 1.5,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.blue.shade400,
width: 2,
),
),
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
),
// Popup properties
popupProps: PopupProps.menu(
showSearchBox: true,
searchFieldProps: TextFieldProps(
decoration: InputDecoration(
hintText: "Cari meja...",
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
),
),
menuProps: MenuProps(
backgroundColor: Colors.white,
elevation: 8,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
itemBuilder: (context, category, isSelected) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
decoration: BoxDecoration(
color: isSelected
? Colors.blue.shade50
: Colors.transparent,
border: Border(
bottom: BorderSide(
color: Colors.grey.shade100,
width: 0.5,
),
),
),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: isSelected
? Colors.blue.shade600
: Colors.grey.shade400,
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
category.tableName,
style: TextStyle(
fontSize: 14,
fontWeight: isSelected
? FontWeight.w600
: FontWeight.w500,
color: isSelected
? Colors.blue.shade700
: Colors.black87,
),
),
),
if (isSelected)
Icon(
Icons.check,
color: Colors.blue.shade600,
size: 18,
),
],
),
);
},
emptyBuilder: (context, searchEntry) {
return Container(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.search_off,
color: Colors.grey.shade400,
size: 48,
),
const SizedBox(height: 12),
Text(
searchEntry.isEmpty
? "Tidak ada meja tersedia"
: "Tidak ditemukan meja dengan '${searchEntry}'",
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 14,
),
textAlign: TextAlign.center,
),
],
),
);
},
),
// Item as string (for search functionality)
itemAsString: (Table table) => table.tableName,
// Comparison function
compareFn: (Table? item1, Table? item2) {
return item1?.id == item2?.id;
},
// On changed callback
onChanged: (Table? selectedTable) {
context.read<CheckoutFormBloc>().add(
CheckoutFormEvent.updateTable(selectedTable),
);
},
// Validator (optional)
validator: (Table? value) {
if (value == null) {
return "Meja harus dipilih";
}
return null;
},
);
},
),
SpaceHeight(24),
BlocBuilder<OrderFormBloc, OrderFormState>(
builder: (context, orderState) {
return AppElevatedButton.filled(
label: 'Simpan',
isLoading: orderState.isCreating,
onPressed: () {
context.read<OrderFormBloc>().add(
OrderFormEvent.createOrder(
items: state.items,
orderType: state.orderType,
table: state.table,
),
);
},
);
},
),
],
),
);
},
);
}
}

View File

@ -0,0 +1,240 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../application/checkout/checkout_form/checkout_form_bloc.dart';
import '../../../../application/order/order_form/order_form_bloc.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../../common/types/order_type.dart';
import '../../button/button.dart';
import '../../spaces/space.dart';
import '../dialog.dart';
class OrderSaveConfirmDialog extends StatelessWidget {
final CheckoutFormState checkoutState;
const OrderSaveConfirmDialog({super.key, required this.checkoutState});
@override
Widget build(BuildContext context) {
return BlocBuilder<OrderFormBloc, OrderFormState>(
builder: (context, state) {
return CustomModalDialog(
title: 'Konfirmasi Pesanan',
subtitle: 'Tindakan ini tidak dapat dibatalkan',
contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 24.0,
),
minWidth: context.deviceWidth * 0.5,
minHeight: context.deviceHeight * 0.8,
bottom: Container(
padding: EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: AppElevatedButton.outlined(
onPressed: () => context.maybePop(),
label: 'Batalkan',
),
),
SpaceWidth(12),
Expanded(
child: AppElevatedButton.filled(
isLoading: state.isCreating,
onPressed: state.isCreating
? null
: () {
context.read<OrderFormBloc>().add(
OrderFormEvent.createOrder(
items: checkoutState.items,
orderType: checkoutState.orderType,
table: checkoutState.table,
),
);
},
label: 'Konfirmasi',
),
),
],
),
),
child: Column(
children: [
Container(
width: double.infinity,
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppColor.textSecondary.withOpacity(0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.border),
),
child: Text(
message(state.customerName ?? '-'),
style: AppStyle.md.copyWith(fontSize: 14, height: 1.4),
),
),
if (checkoutState.items.isNotEmpty) ...[
SpaceHeight(16),
Container(
width: double.infinity,
decoration: BoxDecoration(
color: AppColor.primaryWithOpacity(0.05),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColor.primaryWithOpacity(0.2)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.all(16),
child: Row(
children: [
Container(
padding: EdgeInsets.all(6),
decoration: BoxDecoration(
color: AppColor.primary,
borderRadius: BorderRadius.circular(6),
),
child: Icon(
Icons.list_alt_rounded,
color: AppColor.white,
size: 16,
),
),
SizedBox(width: 8),
Text(
'Item yang akan dipesan:',
style: AppStyle.md.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.primary,
),
),
],
),
),
Container(
constraints: BoxConstraints(maxHeight: 120),
child: Scrollbar(
child: SingleChildScrollView(
padding: EdgeInsets.only(
left: 16,
right: 16,
bottom: 16,
),
child: Column(
children: checkoutState.items.map((item) {
return Container(
margin: EdgeInsets.only(bottom: 6),
padding: EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(6),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.03),
blurRadius: 2,
offset: Offset(0, 1),
),
],
),
child: Row(
children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: AppColor.primary,
shape: BoxShape.circle,
),
),
SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
item.product.name,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
'${item.quantity} qty',
style: AppStyle.sm.copyWith(
color: AppColor.textSecondary,
),
),
],
),
),
Container(
padding: EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColor.primaryWithOpacity(
0.1,
),
borderRadius: BorderRadius.circular(
4,
),
),
child: Text(
((item.product.price) * item.quantity)
.currencyFormatRpV2,
style: AppStyle.md.copyWith(
fontWeight: FontWeight.bold,
color: AppColor.primary,
),
),
),
],
),
);
}).toList(),
),
),
),
),
],
),
),
],
],
),
);
},
);
}
String message(String customerName) {
switch (checkoutState.orderType) {
case OrderType.dineIn:
return 'Konfirmasi untuk menyimpan pesanan Dine-In\n\n'
'Pesanan untuk ${customerName.isNotEmpty ? customerName : "Pelanggan"} akan disimpan ke dalam sistem. '
'Pelanggan dapat langsung menikmati pesanan di meja yang telah disediakan. '
'Pastikan semua item pesanan sudah sesuai sebelum menyimpan.';
case OrderType.delivery:
return 'Konfirmasi untuk menyimpan pesanan Delivery\n\n'
'Pesanan delivery untuk ${customerName.isNotEmpty ? customerName : "Pelanggan"} akan disimpan. '
'${checkoutState.delivery != null ? "Pengiriman: ${checkoutState.delivery?.name ?? "-"}. " : ""}'
'Tim delivery akan segera mempersiapkan pesanan.';
case OrderType.takeAway:
return 'Konfirmasi untuk menyimpan pesanan Take Away\n\n'
'Pesanan take away untuk ${customerName.isNotEmpty ? customerName : "Pelanggan"} akan disimpan. '
'Dapur akan mulai mempersiapkan pesanan dan pelanggan dapat mengambil pesanan sesuai estimasi waktu yang diberikan.';
case OrderType.freeTable:
return 'Konfirmasi untuk menyimpan pesanan Free Table\n\n'
'Pesanan free table untuk ${customerName.isNotEmpty ? customerName : "Pelanggan"} akan disimpan. '
'Meja akan direservasi dan pesanan akan dipersiapkan sesuai dengan waktu kedatangan pelanggan.';
}
}
}

View File

@ -0,0 +1,107 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart' hide Table;
import '../../../../application/checkout/checkout_form/checkout_form_bloc.dart';
import '../../../../common/theme/theme.dart';
import '../../../../common/types/order_type.dart';
import '../../../../domain/table/table.dart';
import '../../spaces/space.dart';
import '../dialog.dart';
import 'order_pay_later_dialog.dart';
import 'order_save_confirm_dialog.dart';
class OrderSaveDialog extends StatelessWidget {
final CheckoutFormState checkoutState;
final List<Table> tables;
const OrderSaveDialog({
super.key,
required this.checkoutState,
required this.tables,
});
@override
Widget build(BuildContext context) {
return CustomModalDialog(
title: 'Pilih Aksi',
subtitle: 'Lanjutkan proses pesanan atau simpan untuk nanti.',
contentPadding: const EdgeInsets.symmetric(
horizontal: 16.0,
vertical: 24.0,
),
child: Column(
children: [
_item(
icon: Icons.schedule_outlined,
title: 'Bayar Nanti',
subtitle: 'Simpan pesanan dan bayar nanti',
onTap: () {
context.maybePop();
if (checkoutState.orderType == OrderType.dineIn) {
Future.delayed(Duration(milliseconds: 100), () {
showDialog(
context: context,
builder: (context) => OrderPayLaterDialog(tables: tables),
);
});
} else {
Future.delayed(Duration(milliseconds: 100), () {
showDialog(
context: context,
builder: (context) =>
OrderSaveConfirmDialog(checkoutState: checkoutState),
);
});
}
},
),
SpaceHeight(16.0),
_item(
icon: Icons.shopping_cart_checkout_outlined,
title: 'Tambahkan Pesanan',
subtitle: 'Tambah item ke daftar pesanan',
onTap: () {},
),
],
),
);
}
Widget _item({
required IconData icon,
required String title,
required String subtitle,
required Function() onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
decoration: BoxDecoration(
color: AppColor.white,
borderRadius: BorderRadius.circular(8.0),
border: Border.all(color: AppColor.border, width: 1.0),
),
child: Row(
children: [
Icon(icon, color: AppColor.primary, size: 26.0),
SpaceWidth(12.0),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppStyle.md.copyWith(fontWeight: FontWeight.w600),
),
Text(
subtitle,
style: AppStyle.md.copyWith(color: AppColor.textSecondary),
),
],
),
],
),
),
);
}
}

View File

@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../application/checkout/checkout_form/checkout_form_bloc.dart';
import '../../../application/order/order_form/order_form_bloc.dart';
import '../../../application/payment_method/payment_method_loader/payment_method_loader_bloc.dart';
import '../../../application/table/table_loader/table_loader_bloc.dart';
import '../../../common/theme/theme.dart';
import '../../../injection.dart';
import '../../components/spaces/space.dart';
@ -19,23 +20,48 @@ class CheckoutPage extends StatelessWidget implements AutoRouteWrapper {
@override
Widget build(BuildContext context) {
return BlocListener<OrderFormBloc, OrderFormState>(
listenWhen: (previous, current) =>
previous.failureOrCreateOrder != current.failureOrCreateOrder,
listener: (context, state) {
state.failureOrCreateOrder.fold(() {}, (either) {
either.fold((f) => AppFlushbar.showOrderFailureToast(context, f), (
order,
) {
if (context.mounted) {
context.read<CheckoutFormBloc>().add(
CheckoutFormEvent.started([]),
return MultiBlocListener(
listeners: [
BlocListener<OrderFormBloc, OrderFormState>(
listenWhen: (previous, current) =>
previous.failureOrCreateOrderWithPayment !=
current.failureOrCreateOrderWithPayment,
listener: (context, state) {
state.failureOrCreateOrderWithPayment.fold(() {}, (either) {
either.fold(
(f) => AppFlushbar.showOrderFailureToast(context, f),
(order) {
if (context.mounted) {
context.read<CheckoutFormBloc>().add(
CheckoutFormEvent.started([]),
);
context.router.replace(SuccessOrderRoute(order: order));
}
},
);
context.router.replace(SuccessOrderRoute(order: order));
}
});
});
},
});
},
),
BlocListener<OrderFormBloc, OrderFormState>(
listenWhen: (previous, current) =>
previous.failureOrCreateOrder != current.failureOrCreateOrder,
listener: (context, state) {
state.failureOrCreateOrder.fold(() {}, (either) {
either.fold(
(f) => AppFlushbar.showOrderFailureToast(context, f),
(order) {
if (context.mounted) {
context.read<CheckoutFormBloc>().add(
CheckoutFormEvent.started([]),
);
context.router.replace(SuccessOrderRoute(order: order));
}
},
);
});
},
),
],
child: SafeArea(
child: Hero(
tag: 'checkout_screen',
@ -80,10 +106,18 @@ class CheckoutPage extends StatelessWidget implements AutoRouteWrapper {
}
@override
Widget wrappedRoute(BuildContext context) => BlocProvider(
create: (context) =>
getIt<PaymentMethodLoaderBloc>()
..add(PaymentMethodLoaderEvent.fetched(isRefresh: true)),
Widget wrappedRoute(BuildContext context) => MultiBlocProvider(
providers: [
BlocProvider(
create: (context) =>
getIt<PaymentMethodLoaderBloc>()
..add(PaymentMethodLoaderEvent.fetched(isRefresh: true)),
),
BlocProvider(
create: (context) => getIt<TableLoaderBloc>()
..add(TableLoaderEvent.fetched(isRefresh: true, status: 'available')),
),
],
child: this,
);
}

View File

@ -1,14 +1,16 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../application/checkout/checkout_form/checkout_form_bloc.dart';
import '../../../../application/order/order_form/order_form_bloc.dart';
import '../../../../application/payment_method/payment_method_loader/payment_method_loader_bloc.dart';
import '../../../../application/table/table_loader/table_loader_bloc.dart';
import '../../../../common/extension/extension.dart';
import '../../../../common/theme/theme.dart';
import '../../../components/button/button.dart';
import '../../../components/card/payment_card.dart';
import '../../../components/dialog/order/order_save_dialog.dart';
import '../../../components/error/payment_method_error_state_widget.dart';
import '../../../components/field/field.dart';
import '../../../components/loader/loader_with_text.dart';
@ -276,36 +278,37 @@ class _CheckoutRightPanelState extends State<CheckoutRightPanel> {
),
SpaceWidth(12),
Expanded(
child: AppElevatedButton.filled(
onPressed: () {
if (customerController.text == '') {
AppFlushbar.showError(
context,
'Pilih Pelanggan terlebih dahulu',
);
return;
}
child: BlocBuilder<TableLoaderBloc, TableLoaderState>(
builder: (context, tableState) {
return AppElevatedButton.filled(
isLoading: tableState.isFetching,
onPressed: () {
if (customerController.text == '') {
AppFlushbar.showError(
context,
'Pilih Pelanggan terlebih dahulu',
);
return;
}
// showDialog(
// context: context,
// builder: (dcontext) => SaveDialog(
// selectedTable: widget.table,
// customerName: customerController.text,
// items: items,
// orderType: orderType,
// customer: selectedCustomer,
// deliveryModel: delivery,
// ),
// );
showDialog(
context: context,
builder: (dcontext) => OrderSaveDialog(
checkoutState: widget.checkoutState,
tables: tableState.tables,
),
);
},
label: 'Simpan',
);
},
label: 'Simpan',
),
),
SpaceWidth(12),
Expanded(
child: AppElevatedButton.filled(
isLoading: orderState.isCreating,
onPressed: orderState.isCreating
isLoading: orderState.isCreatingWithPayment,
onPressed: orderState.isCreatingWithPayment
? null
: () {
if (customerController.text == '') {