QueenFruits/Mobile Commerce/lib/features/checkout/presentation/screens/checkout_screen.dart

1845 lines
61 KiB
Dart

import 'dart:io';
import 'dart:math';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import 'package:latlong2/latlong.dart';
import 'package:niogu_ecommerce_v1/core/constant/app_asset.dart';
import 'package:niogu_ecommerce_v1/core/constant/app_font_size.dart';
import 'package:niogu_ecommerce_v1/core/enums/action_type.dart';
import 'package:niogu_ecommerce_v1/core/enums/delivery_fee_type.dart';
import 'package:niogu_ecommerce_v1/core/enums/delivery_type.dart';
import 'package:niogu_ecommerce_v1/core/enums/order_status.dart';
import 'package:niogu_ecommerce_v1/core/errors/exceptions.dart';
import 'package:niogu_ecommerce_v1/core/models/tenant_payment_method_model.dart';
import 'package:niogu_ecommerce_v1/core/providers/app_provider.dart';
import 'package:niogu_ecommerce_v1/core/router/app_route.dart';
import 'package:niogu_ecommerce_v1/core/system/system_setting.dart';
import 'package:niogu_ecommerce_v1/core/utils/currency_format.dart';
import 'package:niogu_ecommerce_v1/core/utils/extenstion_format.dart';
import 'package:niogu_ecommerce_v1/core/utils/image_service.dart';
import 'package:niogu_ecommerce_v1/core/utils/log_message.dart';
import 'package:niogu_ecommerce_v1/core/utils/time_zone.dart';
import 'package:niogu_ecommerce_v1/core/widgets/custom_snackbar.dart';
import 'package:niogu_ecommerce_v1/features/account/domain/entities/account.dart';
import 'package:niogu_ecommerce_v1/features/account/presentation/providers/account_provider.dart';
import 'package:niogu_ecommerce_v1/features/cart/domain/entities/cart.dart';
import 'package:niogu_ecommerce_v1/features/cart/presentation/providers/cart_provider.dart';
import 'package:niogu_ecommerce_v1/features/checkout/domain/entities/checkout.dart';
import 'package:niogu_ecommerce_v1/features/checkout/presentation/providers/checkout_provider.dart';
import 'package:niogu_ecommerce_v1/features/home/presentation/providers/home_provider.dart';
import 'package:sizer/sizer.dart';
import 'package:niogu_ecommerce_v1/core/constant/app_color.dart';
class CheckoutScreen extends ConsumerStatefulWidget {
const CheckoutScreen({super.key});
@override
ConsumerState<CheckoutScreen> createState() => _CheckoutScreenState();
}
class _CheckoutScreenState extends ConsumerState<CheckoutScreen> {
final _noteController = TextEditingController();
DeliveryType _deliveryType = DeliveryType.delivery;
String _selectedPaymentMethod = "COD";
String? _deliveryPreference;
bool _isPaymentExpanded = false;
final List<String?> _paymentProofPathTemps = [];
final ImagePicker _picker = ImagePicker();
String? _paymentProofPath;
@override
void initState() {
// TODO: implement initState
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.invalidate(selectedAddressProvider);
});
}
@override
void dispose() {
// TODO: implement dispose
_noteController.dispose();
super.dispose();
}
double _calculateTotalOrder(List<CartItem> cartItems) {
return cartItems.fold(0.0, (sum, cartItem) {
return sum + cartItem.sellingPrice * cartItem.quantity;
});
}
List<DateTime> _generateAvailableSlots({
required int onlineOpenTime,
required int onlineCloseTime,
required int preparationTimeMinutes,
required int orderIntervalMinutes,
}) {
final slotDuration = Duration(
minutes: preparationTimeMinutes + orderIntervalMinutes,
);
final now = DateTime.now();
final List<DateTime> slots = [];
final asap = _getEarliestDeliveryTime(
onlineOpenTime: onlineOpenTime,
preparationTimeMinutes: preparationTimeMinutes,
orderIntervalMinutes: orderIntervalMinutes,
);
final openingTime = DateTime(
now.year,
now.month,
now.day,
onlineOpenTime,
0,
);
final closingTime = DateTime(
now.year,
now.month,
now.day,
onlineCloseTime,
0,
);
DateTime currentSlot = now.isBefore(openingTime)
? openingTime
: now.add(Duration(minutes: preparationTimeMinutes));
currentSlot = _roundToNextSlot(
currentSlot,
preparationTimeMinutes: preparationTimeMinutes,
orderIntervalMinutes: orderIntervalMinutes,
);
while (currentSlot.isBefore(closingTime)) {
final end = currentSlot.add(slotDuration);
if (end.isAfter(_getLastAllowedEndTime(now, onlineCloseTime))) break;
if (currentSlot.isAfter(asap)) {
slots.add(currentSlot);
}
currentSlot = currentSlot.add(slotDuration);
}
return slots;
}
DateTime _getLastAllowedEndTime(DateTime now, int onlineCloseTime) {
return DateTime(now.year, now.month, now.day, onlineCloseTime);
}
DateTime _getEarliestDeliveryTime({
required int onlineOpenTime,
required int preparationTimeMinutes,
required int orderIntervalMinutes,
}) {
final now = DateTime.now();
final openingTime = DateTime(now.year, now.month, now.day, onlineOpenTime);
final baseTime = now.isBefore(openingTime) ? openingTime : now;
final earliest = baseTime.add(Duration(minutes: preparationTimeMinutes));
return _roundToNextSlot(
earliest,
preparationTimeMinutes: preparationTimeMinutes,
orderIntervalMinutes: orderIntervalMinutes,
);
}
DateTime _roundToNextSlot(
DateTime time, {
required int preparationTimeMinutes,
required int orderIntervalMinutes,
}) {
final slotDuration = Duration(
minutes: preparationTimeMinutes + orderIntervalMinutes,
);
final totalMinutes = time.hour * 60 + time.minute;
final slotMinutes = slotDuration.inMinutes;
final remainder = totalMinutes % slotMinutes;
if (remainder == 0) return time;
final minutesToAdd = slotMinutes - remainder;
return time.add(Duration(minutes: minutesToAdd));
}
String _formatRange(DateTime start) {
final end = start.add(Duration(minutes: 25));
final timeZone = TimeZone.getCurrentTimeZone();
return "${DateFormat('HH:mm').format(start)} $timeZone - ${DateFormat('HH:mm').format(end)} $timeZone";
}
Future<void> _showPickerOptions() async {
final bool isTablet = 100.w >= 600;
showModalBottomSheet(
context: context,
backgroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(2.5.w)),
),
constraints: const BoxConstraints(maxWidth: double.infinity),
builder: (BuildContext context) {
return SafeArea(
child: Container(
width: 100.w,
padding: EdgeInsets.symmetric(vertical: 2.h, horizontal: 5.w),
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(6.w)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: Icon(Icons.photo_library, size: 6.w),
title: Text(
'Galeri',
style: TextStyle(
fontSize: isTablet
? AppFontSize.medium.sp
: AppFontSize.small.sp,
),
),
onTap: () async {
await _getImage(ImageSource.gallery);
context.pop();
},
),
SizedBox(height: 2.h),
ListTile(
leading: Icon(Icons.photo_camera, size: 6.w),
title: Text(
'Kamera',
style: TextStyle(
fontSize: isTablet
? AppFontSize.medium.sp
: AppFontSize.small.sp,
),
),
onTap: () async {
await _getImage(ImageSource.camera);
context.pop();
},
),
],
),
),
);
},
);
}
Future<void> _getImage(ImageSource ImageSource) async {
try {
final XFile? pickedFile = await _picker.pickImage(
source: ImageSource,
imageQuality: 70,
);
if (pickedFile != null) {
File tempFile = File(pickedFile.path);
String? imagePath = await ImageService.saveImageToLocalDirectory(
tempFile,
"payment_proof_orders",
);
if (imagePath != null) {
_paymentProofPathTemps.add(imagePath);
setState(() {
_paymentProofPath = imagePath;
});
}
}
} catch (e, st) {
LogMessage.log.w(e.toString(), error: e, stackTrace: st);
CustomSnackbar.showWarning(context, "Akses ditolak");
}
}
Future<void> _removeImage() async {
setState(() {
_paymentProofPath = null;
});
}
Future<MultipartFile?> _getPaymentProofFile() async {
if (_paymentProofPath == null) return null;
final file = File(_paymentProofPath!);
if (!await file.exists()) {
LogMessage.log.e("File fisik tidak ditemukan: $_paymentProofPath");
return null;
}
return await MultipartFile.fromFile(_paymentProofPath!);
}
String _fetchPaymentMethodAsset(String method) {
return switch (method) {
'dana' => AppAsset.DANA_LOGO,
'gopay' => AppAsset.GOPAY_LOGO,
'shopeepay' => AppAsset.SHOPEEPAY_LOGO,
'bri' => AppAsset.MANDIRI_LOGO,
'mandiri' => AppAsset.MANDIRI_LOGO,
'bni' => AppAsset.BNI_LOGO,
'bca' => AppAsset.BCA_LOGO,
String() => 'error',
};
}
double _calculateDistance(
double lat1,
double lon1,
double lat2,
double lon2,
) {
final p = 0.017453292519943295;
final c = cos;
final a =
0.5 -
c((lat2 - lat1) * p) / 2 +
c(lat1 * p) * c(lat2 * p) * (1 - c((lon2 - lon1) * p)) / 2;
return 12742 * asin(sqrt(a));
}
Future<void> _checkout({
String? deliveryFeeType,
required double deliveryFee,
required double totalDeliveryFee,
required double maxDeliveryRadiusKm,
}) async {
final currentOutletId = ref.read(currentOutletIdProvider);
final currentOutletName = ref.read(currentOutletNameProvider);
final currentOutletPhoneNumber = ref.read(currentOutletPhoneProvider);
final currentOutletLocation = ref.read(currentOutletLocationProvider);
final currentOutletCoordinate = ref.read(currentOutletCoordinateProvider);
final currentOutletHasLocation =
currentOutletLocation != null && currentOutletCoordinate != null;
final currentCustomerId = ref.read(currentCustomerIdProvider);
final currentCustomerName = ref.read(currentCustomerNameProvider);
final currentCustomerPhone = ref.read(currentCustomerPhoneProvider);
final selectedAddress = ref.read(selectedAddressProvider);
if (_deliveryType == DeliveryType.delivery) {
if (!currentOutletHasLocation) {
CustomSnackbar.showError(context, "Metode pengiriman belum tersedia");
return;
}
if (selectedAddress == null) {
CustomSnackbar.showError(context, "Alamat pengiriman belum diatur");
return;
}
final distance = _calculateDistance(
currentOutletCoordinate.latitude,
currentOutletCoordinate.longitude,
selectedAddress.latitude,
selectedAddress.longitude,
);
if (maxDeliveryRadiusKm > 0 && distance > maxDeliveryRadiusKm) {
CustomSnackbar.showError(
context,
"Jangkauan pengiriman kami maks ${maxDeliveryRadiusKm.toStringWithoutTrailingZero()} Km",
);
return;
}
}
if (_deliveryPreference == null) {
CustomSnackbar.showError(context, "Waktu pengiriman belum dipilih");
return;
}
final isPayOnTheSpot =
((_deliveryType == DeliveryType.delivery &&
_selectedPaymentMethod == 'COD') ||
(_deliveryType == DeliveryType.pick_up &&
_selectedPaymentMethod == 'Bayar Ditoko'));
if (!isPayOnTheSpot && _paymentProofPath == null) {
CustomSnackbar.showError(context, "Bukti pembayaran belum di upload");
return;
}
final deliveryPreference = _deliveryPreference == "Segera"
? "(Segera)"
: "($_deliveryPreference)";
final cartItems = ref.watch(cartItemProvider);
String? notes;
if (_noteController.text.trim().isNotEmpty) {
notes = _noteController.text.trim();
}
final totalOrder = _calculateTotalOrder(cartItems);
final order = OnlineOrder(
outletId: currentOutletId!,
outletNameSnapshot: currentOutletName!,
outletPhoneNumberSnapshot: currentOutletPhoneNumber,
outletAddressSnapshot: !currentOutletHasLocation
? null
: OutletAddressSnapshot(
fullAddress: currentOutletLocation,
latitude: currentOutletCoordinate.latitude,
longitude: currentOutletCoordinate.longitude,
),
customerId: currentCustomerId!,
customerNameSnapshot: currentCustomerName!,
customerPhoneNumberSnapshot: currentCustomerPhone!,
customerAddressSnapshot: selectedAddress == null
? null
: CustomerAddressSnapshot(
label: selectedAddress.label!,
fullAddress: selectedAddress.fullAddress,
latitude: selectedAddress.latitude,
longitude: selectedAddress.longitude,
),
orderStatus: isPayOnTheSpot
? OrderStatus.pending_confirmation
: OrderStatus.pending_payment,
deliveryType: _deliveryType,
deliveryPreference: "Hari ini $deliveryPreference",
deliveryFeeType: deliveryFeeType,
deliveryFee: deliveryFee,
totalDeliveryFee: totalDeliveryFee,
totalOrder: totalOrder,
totalAmount: totalOrder + totalDeliveryFee,
notes: notes,
paymentProofFile: await _getPaymentProofFile(),
paymentMethod: _selectedPaymentMethod,
items: cartItems.map((item) {
return OnlineOrderItem(
productVariantId: item.id,
quantity: item.quantity,
productImageUrlSnapshot: item.image?.toRelativeImagePath(),
productNameSnapshot: item.name,
productVariantNameSnapshot: item.isProductVariant
? item.variantName
: null,
sellingPriceSnapshot: item.sellingPrice,
subtotal: item.quantity * item.sellingPrice,
);
}).toList(),
);
try {
final data = await ref
.read(checkoutControllerProvider.notifier)
.checkout(order);
if (!mounted) return;
ref.read(cartItemControllerProvider.notifier).clear();
await SystemSetting.saveCartItemByOutlet([]);
if (data != null) {
context.pushNamed(
AppRoute.customActionScreen,
extra: {
'order_id': data.orderId,
'order_number': data.orderNumber,
'type': ActionType.checkout,
},
);
}
} on ServerException catch (e, st) {
LogMessage.log.e(e.toString(), error: e, stackTrace: st);
CustomSnackbar.showError(context, e.message);
}
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final currentCustomerName = ref.watch(currentCustomerNameProvider);
final currentCustomerPhone = ref.watch(currentCustomerPhoneProvider);
final currentOutletName = ref.watch(currentOutletNameProvider);
final currentOutletCoordinate = ref.watch(
currentOutletCoordinateProvider,
);
final selectedAddress = ref.watch(selectedAddressProvider);
final cartItems = ref.watch(cartItemProvider);
final totalOrder = _calculateTotalOrder(cartItems);
final isAddressSelected = selectedAddress != null;
final configureState = ref.watch(configurationControllerProvider);
final checkoutControllerState = ref.watch(checkoutControllerProvider);
final isLoading = checkoutControllerState.isLoading;
return SafeArea(
top: false,
bottom: true,
right: false,
left: false,
child: Scaffold(
backgroundColor: const Color(0xFFF5F5F5),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0.5,
leading: IconButton(
icon: Icon(
Icons.arrow_back,
size: 7.w,
color: AppColor.primaryColor,
),
onPressed: () => context.pop(),
),
title: Text(
"Checkout",
style: TextStyle(
color: Colors.black,
fontSize: AppFontSize.medium.sp,
fontWeight: FontWeight.bold,
),
),
),
body: Stack(
children: [
SingleChildScrollView(
child: Column(
children: [
_buildDeliveryAddressSection(
customerName: currentCustomerName!,
customerPhone: currentCustomerPhone!,
address: selectedAddress,
),
SizedBox(height: 1.25.h),
_buildDeliveryTypeSection(),
SizedBox(height: 1.25.h),
_buildProductSummarySection(
cartItems,
currentOutletName!,
currentOutletCoordinate,
selectedAddress,
),
SizedBox(height: 1.25.h),
configureState.maybeWhen(
data: (configure) {
if (configure == null) return const SizedBox();
var paymentMethods = configure.paymentMethods;
if (configure.allowCod!) {
paymentMethods = [
TenantPaymentMethodModel(
uuid: '',
sourceName:
_deliveryType == DeliveryType.delivery
? 'COD'
: 'Bayar Ditoko',
accountNumber: '',
receipentName: '',
createdAt: '',
updatedAt: '',
),
...paymentMethods,
];
}
final timeFormat = DateFormat('HH.mm');
final isCloseService =
configure.isCloseService ?? false;
final onlineOpenTime = timeFormat
.parse(configure.onlineOpenTime!)
.hour;
final onlineCloseTime = timeFormat
.parse(configure.onlineCloseTime!)
.hour;
final preparationTimeMinutes =
configure.preparationTimeMinutes!;
final orderIntervalMinutes =
configure.orderIntervalMinutes!;
final deliveryFeeType = DeliveryFeeType.values.byName(
configure.deliveryFeeType!,
);
double deliveryFee = 0.0;
double totalDeliveryFee = 0.0;
double distance = 1;
if (deliveryFeeType == DeliveryFeeType.fixed) {
deliveryFee = configure.deliveryFlatFee!;
totalDeliveryFee = deliveryFee;
} else if (currentOutletCoordinate != null &&
selectedAddress != null) {
distance = _calculateDistance(
currentOutletCoordinate.latitude,
currentOutletCoordinate.longitude,
selectedAddress.latitude,
selectedAddress.longitude,
);
deliveryFee = configure.deliveryFeePerKm!;
totalDeliveryFee = deliveryFee * distance;
}
var strDistance = '${distance.toStringAsFixed(1)} Km';
if (distance < 1) {
strDistance =
'${(distance * 1000).toStringAsFixed(0)} meter';
}
var totalAmount = totalOrder + totalDeliveryFee;
if (_deliveryType == DeliveryType.pick_up)
totalAmount = totalOrder;
return Column(
children: [
_buildDeliveryPreferenceSection(
isCloseService: isCloseService,
onlineOpenTime: onlineOpenTime,
onlineCloseTime: onlineCloseTime,
preparationTimeMinutes: preparationTimeMinutes,
orderIntervalMinutes: orderIntervalMinutes,
),
SizedBox(height: 1.25.h),
_buildPaymentMethodSection(paymentMethods),
SizedBox(height: 1.25.h),
_buildPaymentDetailSection(
totalOrder: totalOrder,
isAddressSelcted: isAddressSelected,
deliveryFeeType: deliveryFeeType,
deliveryFee: deliveryFee,
strDistance: strDistance,
totalDeliveryFee: totalDeliveryFee,
totalAmount: totalAmount,
),
],
);
},
orElse: () => const SizedBox(),
),
SizedBox(height: 1.25.h),
_buildNoteSection(),
SizedBox(height: 1.25.h),
],
),
),
if (configureState.isRefreshing)
Container(
color: Colors.black.withOpacity(0.5),
child: Center(
child: const CircularProgressIndicator(
color: AppColor.primaryColor,
backgroundColor: Colors.white,
),
),
),
if (isLoading)
Container(
color: Colors.white54,
child: Center(
child: const CircularProgressIndicator(
color: AppColor.primaryColor,
backgroundColor: Colors.white,
),
),
),
],
),
bottomNavigationBar: configureState.maybeWhen(
data: (configure) {
if (configure == null) return const SizedBox();
final deliveryType = DeliveryFeeType.values.byName(
configure.deliveryFeeType!,
);
double deliveryFee = 0.0;
double totalDeliveryFee = 0.0;
if (_deliveryType == DeliveryType.delivery) {
if (deliveryType == DeliveryFeeType.fixed) {
deliveryFee = configure.deliveryFlatFee!;
totalDeliveryFee = deliveryFee;
} else {
double distance = 1;
if (currentOutletCoordinate != null &&
selectedAddress != null)
distance = _calculateDistance(
currentOutletCoordinate.latitude,
currentOutletCoordinate.longitude,
selectedAddress.latitude,
selectedAddress.longitude,
);
deliveryFee = configure.deliveryFeePerKm!;
totalDeliveryFee = deliveryFee * distance;
}
}
final totalAmount = totalOrder + totalDeliveryFee;
final maxDeliveryRadiusKm = configure.maxDeliveryRadiusKm;
return _buildBottomCheckoutAction(
deliveryFeeType: deliveryType.type,
deliveryFee: deliveryFee,
isLoading: isLoading,
totalAmount: totalAmount,
totalDeliveryFee: totalDeliveryFee,
maxDeliveryRadiusKm: maxDeliveryRadiusKm ?? 0.0,
);
},
orElse: () => const SizedBox(),
),
),
);
},
);
}
Widget _buildDeliveryAddressSection({
required String customerName,
required String customerPhone,
SelectedAddress? address,
}) {
if (address == null)
return GestureDetector(
onTap: () {
context.pushNamed(AppRoute.shippingAddressScreen);
},
child: Container(
color: Colors.white,
padding: EdgeInsets.all(4.w),
child: Container(
padding: EdgeInsets.symmetric(vertical: 2.h, horizontal: 4.w),
decoration: BoxDecoration(
color: AppColor.primaryColor.withOpacity(0.05),
borderRadius: BorderRadius.circular(2.5.w),
border: Border.all(
color: AppColor.primaryColor.withOpacity(0.2),
style: BorderStyle.solid,
),
),
child: Row(
children: [
CircleAvatar(
radius: 6.w,
backgroundColor: Colors.white,
child: Icon(
Icons.add_location_alt_outlined,
color: AppColor.primaryColor,
size: 7.w,
),
),
SizedBox(width: 4.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Alamat Belum Dipilih",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: (AppFontSize.small - 0.5).sp,
color: Colors.black87,
),
),
SizedBox(height: 0.5.h),
Text(
"Klik di sini untuk memilih alamat pengiriman",
style: TextStyle(
fontSize: (AppFontSize.small - 1.5).sp,
color: Colors.grey.shade600,
),
),
],
),
),
Icon(
Icons.arrow_forward_ios,
color: AppColor.primaryColor,
size: 4.w,
),
],
),
),
),
);
return Container(
color: Colors.white,
padding: EdgeInsets.all(4.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
Icon(
Icons.location_on,
color: AppColor.primaryColor,
size: 5.w,
),
SizedBox(width: 2.w),
Text(
"Alamat Pengiriman",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: AppFontSize.small.sp,
),
),
],
),
GestureDetector(
onTap: () {
context.pushNamed(AppRoute.shippingAddressScreen);
},
child: Text(
"Ubah",
style: TextStyle(
color: AppColor.primaryColor,
fontWeight: FontWeight.bold,
fontSize: (AppFontSize.small - 1.25).sp,
),
),
),
],
),
SizedBox(height: 1.5.h),
Container(
padding: EdgeInsets.symmetric(horizontal: 2.w, vertical: 1.h),
decoration: BoxDecoration(
color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(1.5.w),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.home, size: 3.5.w, color: Colors.grey.shade500),
SizedBox(width: 2.w),
Text(
address.label!,
style: TextStyle(
color: Colors.grey.shade700,
fontSize: (AppFontSize.small - 1.25).sp,
),
),
],
),
),
SizedBox(height: 0.75.h),
Text(
"$customerName ($customerPhone)",
style: TextStyle(
fontSize: (AppFontSize.small - 1.25).sp,
fontWeight: FontWeight.bold,
),
),
Text(
address.fullAddress,
style: TextStyle(
fontSize: (AppFontSize.small - 1.25).sp,
color: Colors.black87,
height: 1.4,
),
),
],
),
);
}
Widget _buildDeliveryTypeSection() {
return Container(
color: Colors.white,
padding: EdgeInsets.all(4.w),
child: Column(
children: [
Row(
children: [
_buildChoiceChip(
"Pengiriman",
DeliveryType.delivery,
_deliveryType == DeliveryType.delivery,
),
SizedBox(width: 3.w),
_buildChoiceChip(
"Ambil Ditoko",
DeliveryType.pick_up,
_deliveryType == DeliveryType.pick_up,
),
],
),
/**
Divider(height: 3.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Waktu Pengiriman",
style: TextStyle(fontSize: (AppFontSize.small - 1.25).sp),
),
Row(
children: [
Icon(Icons.access_time, size: 14.sp, color: Colors.grey),
SizedBox(width: 1.w),
Text(
"Sekarang (30-40 Menit)",
style: TextStyle(
fontSize: (AppFontSize.small - 1.25).sp,
fontWeight: FontWeight.bold,
),
),
Icon(Icons.chevron_right, color: Colors.grey),
],
),
],
),
*/
],
),
);
}
Widget _buildChoiceChip(
String label,
DeliveryType deliveryType,
bool isSelected,
) {
return Expanded(
child: GestureDetector(
onTap: () => setState(() => _deliveryType = deliveryType),
child: Container(
padding: EdgeInsets.symmetric(vertical: 1.2.h),
decoration: BoxDecoration(
color: isSelected ? AppColor.primaryColor : Colors.grey.shade100,
borderRadius: BorderRadius.circular(2.w),
),
alignment: Alignment.center,
child: Text(
label,
style: TextStyle(
color: isSelected ? Colors.white : Colors.black87,
fontSize: AppFontSize.small.sp,
fontWeight: FontWeight.bold,
),
),
),
),
);
}
Widget _buildProductSummarySection(
List<CartItem> items,
String outletName,
LatLng? outletCoordinate,
SelectedAddress? address,
) {
var distance = 0.0;
if (outletCoordinate != null && address != null) {
distance = _calculateDistance(
outletCoordinate.latitude,
outletCoordinate.longitude,
address.latitude,
address.longitude,
);
}
var strDistance = '${distance.toStringAsFixed(1)} Km';
if (distance < 1) {
strDistance = '${(distance * 1000).toStringAsFixed(0)} meter';
}
return Container(
color: Colors.white,
padding: EdgeInsets.all(4.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: EdgeInsets.all(3.w),
decoration: BoxDecoration(
color: AppColor.primaryColor.withOpacity(0.05),
borderRadius: BorderRadius.circular(2.w),
border: Border.all(color: AppColor.primaryColor.withOpacity(0.1)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Container(
padding: EdgeInsets.all(2.w),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
child: Icon(
Icons.storefront,
color: AppColor.primaryColor,
size: 5.w,
),
),
SizedBox(width: 3.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_deliveryType == DeliveryType.delivery
? "Pesanan dikirim dari:"
: "Tempat pengambilan:",
style: TextStyle(
fontSize: (AppFontSize.small - 1.25).sp,
color: Colors.grey.shade600,
),
),
SizedBox(height: 0.5.h),
Text(
outletName,
style: TextStyle(
fontSize: (AppFontSize.small - 1.25).sp,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
if (distance > 0) ...[
SizedBox(height: 0.5.h),
Text(
"Jarak \u00B1 $strDistance dari lokasi kamu",
style: TextStyle(
fontSize: (AppFontSize.small - 1.25).sp,
color: AppColor.primaryColor,
fontWeight: FontWeight.w500,
),
),
],
],
),
),
],
),
),
Divider(height: 4.h),
...List.generate(items.length, (index) {
final item = items[index];
return Padding(
padding: EdgeInsets.only(bottom: 2.h),
child: Row(
children: [
CachedNetworkImage(
imageUrl: item.image ?? 'error',
imageBuilder: (context, imageProvider) {
return Container(
width: 15.w,
height: 15.w,
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: BoxBorder.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(2.w),
image: DecorationImage(
image: imageProvider,
fit: BoxFit.cover,
),
),
);
},
errorWidget: (context, url, error) {
return Container(
width: 15.w,
height: 15.w,
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(2.w),
),
child: Icon(Icons.image, color: Colors.grey.shade300),
);
},
),
SizedBox(width: 3.w),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: AppFontSize.small.sp),
),
if (item.isProductVariant)
Text(
item.variantName,
style: TextStyle(
fontSize: (AppFontSize.small - 1.25).sp,
color: Colors.grey.shade500,
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
CurrencyFormat.formatToIdr(item.sellingPrice, 0),
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: (AppFontSize.small - 1.25).sp,
color: AppColor.primaryColor,
),
),
Text(
"x ${item.quantity}",
style: TextStyle(
color: Colors.grey.shade500,
fontSize: AppFontSize.small.sp,
),
),
],
),
],
),
),
],
),
);
}),
],
),
);
}
Widget _buildDeliveryPreferenceSection({
required bool isCloseService,
required int onlineOpenTime,
required int onlineCloseTime,
required int preparationTimeMinutes,
required int orderIntervalMinutes,
}) {
var slots = [];
if (preparationTimeMinutes > 0 || orderIntervalMinutes > 0)
slots = _generateAvailableSlots(
onlineOpenTime: onlineOpenTime,
onlineCloseTime: onlineCloseTime,
preparationTimeMinutes: preparationTimeMinutes,
orderIntervalMinutes: orderIntervalMinutes,
);
return Container(
color: Colors.white,
padding: EdgeInsets.all(4.w),
child: isCloseService || DateTime.now().hour >= onlineCloseTime
? Center(
child: Text(
"Waktu pengiriman tidak tersedia",
style: TextStyle(
color: Colors.grey.shade500,
fontSize: AppFontSize.small.sp,
),
),
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_deliveryType == DeliveryType.delivery
? "Waktu Pengiriman"
: "Waktu Pengambilan",
style: TextStyle(
fontSize: AppFontSize.small.sp,
fontWeight: FontWeight.bold,
),
),
SizedBox(height: 1.h),
Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline,
size: 3.5.w,
color: AppColor.primaryColor,
),
SizedBox(width: 0.75.w),
Text(
_deliveryType == DeliveryType.delivery
? "Waktu produk kamu dikirim dari toko"
: "Waktu produk kamu di ambil di toko",
style: TextStyle(
color: AppColor.primaryColor,
fontSize: (AppFontSize.small - 1.25).sp,
),
),
],
),
],
),
/**
SizedBox(height: 2.h),
Row(
children: [
_buildDayRadio("Sekarang"),
SizedBox(width: 5.w),
_buildDayRadio("Besok"),
],
),
*/
SizedBox(height: 2.h),
_buildDayRadio(),
SizedBox(height: 2.5.h),
SingleChildScrollView(
clipBehavior: Clip.none,
scrollDirection: Axis.horizontal,
child: Row(
children: [
if (DateTime.now().hour >= onlineOpenTime)
_buildTimeButton(
label: "Segera",
icon: Icons.history,
isActive: _deliveryPreference == "Segera",
onTap: () =>
setState(() => _deliveryPreference = "Segera"),
),
if (slots.isNotEmpty)
...List.generate(slots.length, (index) {
final label = _formatRange(slots[index]);
return _buildTimeButton(
label: label,
icon: Icons.access_time,
isActive: _deliveryPreference == label,
onTap: () {
setState(() => _deliveryPreference = label);
},
);
}),
],
),
),
],
),
);
}
Widget _buildDayRadio() {
return Row(
children: [
Icon(
Icons.radio_button_checked,
color: AppColor.primaryColor,
size: 5.w,
),
SizedBox(width: 2.w),
Text(
"Hari Ini",
style: TextStyle(
fontSize: (AppFontSize.small - 1.25).sp,
color: AppColor.primaryColor,
fontWeight: FontWeight.bold,
),
),
],
);
}
Widget _buildTimeButton({
required String label,
required IconData icon,
required bool isActive,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 45.w,
padding: EdgeInsets.symmetric(vertical: 1.2.h, horizontal: 2.w),
margin: EdgeInsets.only(right: 3.w),
decoration: BoxDecoration(
color: isActive ? AppColor.primaryColor : Colors.grey.shade50,
borderRadius: BorderRadius.circular(2.w),
border: Border.all(
color: isActive ? AppColor.primaryColor : Colors.grey.shade300,
),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 5.w, color: isActive ? Colors.white : Colors.grey),
SizedBox(width: 2.w),
Flexible(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: (AppFontSize.small - 1.25).sp,
fontWeight: FontWeight.bold,
color: isActive ? Colors.white : Colors.grey.shade700,
),
),
),
],
),
),
);
}
Widget _buildNoteSection() {
return Container(
color: Colors.white,
padding: EdgeInsets.all(4.w),
child: TextField(
controller: _noteController,
style: TextStyle(fontSize: AppFontSize.small.sp),
decoration: InputDecoration(
hintText: "Tambah Catatan",
hintStyle: TextStyle(fontSize: AppFontSize.small.sp),
prefixIcon: Icon(
Icons.note_alt_outlined,
color: Colors.grey,
size: 5.w,
),
filled: true,
fillColor: Colors.grey.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
),
),
);
}
Widget _buildPaymentMethodSection(
List<TenantPaymentMethodModel> paymentMethods,
) {
if (_selectedPaymentMethod == "COD" ||
_selectedPaymentMethod == 'Bayar Ditoko') {
_selectedPaymentMethod = _deliveryType == DeliveryType.delivery
? "COD"
: "Bayar Ditoko";
}
final visibleMethods = _isPaymentExpanded
? paymentMethods
: paymentMethods.take(3).toList();
return Container(
color: Colors.white,
padding: EdgeInsets.all(4.w),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Metode Pembayaran",
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: AppFontSize.small.sp,
),
),
SizedBox(height: 2.h),
...List.generate(visibleMethods.length, (index) {
final method = visibleMethods[index];
return Column(
children: [
RadioListTile(
value: method.sourceName.capitalize(),
groupValue: _selectedPaymentMethod,
onChanged: (val) {
setState(() {
_selectedPaymentMethod = val!;
_paymentProofPath = null;
});
},
activeColor: AppColor.primaryColor,
contentPadding: EdgeInsets.zero,
title: Row(
children: [
method.sourceName == 'COD' ||
method.sourceName == 'Bayar Ditoko'
? Icon(
Icons.payments_outlined,
size: 7.w,
color: AppColor.primaryColor,
)
: Image.asset(
_fetchPaymentMethodAsset(method.sourceName),
width: 7.w,
fit: BoxFit.contain,
),
SizedBox(width: 2.5.w),
Text(
method.sourceName.capitalize(),
style: TextStyle(
fontSize: (AppFontSize.small - 1.25).sp,
),
),
],
),
),
if (_selectedPaymentMethod == method.sourceName.capitalize() &&
method.sourceName != 'COD' &&
method.sourceName != 'Bayar Ditoko')
_buildUploadProofSection(method),
],
);
}),
if (paymentMethods.length > 3)
Center(
child: TextButton.icon(
style: TextButton.styleFrom(overlayColor: Colors.transparent),
onPressed: () =>
setState(() => _isPaymentExpanded = !_isPaymentExpanded),
icon: Icon(
_isPaymentExpanded
? Icons.keyboard_arrow_up
: Icons.keyboard_arrow_down,
color: Colors.grey,
size: 5.w,
),
label: Text(
_isPaymentExpanded ? "Tutup" : "Selengkapnya",
style: TextStyle(
color: Colors.grey,
fontSize: (AppFontSize.small - 1.25).sp,
),
),
),
),
],
),
);
}
Widget _buildUploadProofSection(TenantPaymentMethodModel paymentMethod) {
final File imageFile = File(_paymentProofPath ?? "image not found");
final bool imageFileExists = imageFile.existsSync();
return Container(
margin: EdgeInsets.only(left: 12.w, bottom: 2.h, right: 2.w),
padding: EdgeInsets.all(3.w),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(2.w),
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Tujuan Transfer:",
style: TextStyle(
fontSize: (AppFontSize.small - 1.25).sp,
color: Colors.grey.shade700,
),
),
SizedBox(height: 0.5.h),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
paymentMethod.accountNumber,
style: TextStyle(
fontSize: (AppFontSize.small - 1.25).sp,
fontWeight: FontWeight.bold,
color: AppColor.primaryColor,
),
),
Text(
paymentMethod.receipentName,
style: TextStyle(
fontSize: (AppFontSize.small - 1.25).sp,
color: Colors.black87,
),
),
],
),
),
IconButton(
onPressed: () {
Clipboard.setData(
ClipboardData(text: paymentMethod.accountNumber),
);
CustomSnackbar.showSuccess(
context,
"Nomor akun berhasil disalin",
);
},
icon: Icon(Icons.copy, size: 5.w, color: Colors.grey),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
),
],
),
Divider(height: 3.h, color: Colors.grey.shade200),
Text(
"Upload Bukti Pembayaran",
style: TextStyle(
fontSize: (AppFontSize.small - 1.25).sp,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
SizedBox(height: 1.5.h),
InkWell(
onTap: _showPickerOptions,
child: Stack(
children: [
imageFileExists
? GestureDetector(
onTap: () {
if (!imageFileExists) {
CustomSnackbar.showError(
context,
"Ups, terjadi kesalahan",
);
return;
}
showDialog(
context: context,
useRootNavigator: true,
builder: (context) => Dialog(
insetPadding: EdgeInsets.symmetric(
horizontal: 2.w,
vertical: 2.h,
),
backgroundColor: Colors.transparent,
child: GestureDetector(
onTap: () => Navigator.pop(context),
child: Container(
width: MediaQuery.of(context).size.width,
constraints: BoxConstraints(
maxWidth: 85.w,
maxHeight: 85.h,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(4.w),
child: InteractiveViewer(
child: Image.file(
imageFile,
fit: BoxFit.contain,
width: double.infinity,
),
),
),
),
),
),
);
},
child: Container(
width: double.infinity,
height: 12.h,
decoration: BoxDecoration(
border: BoxBorder.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(2.w),
image: DecorationImage(
image: FileImage(imageFile),
fit: BoxFit.cover,
),
),
),
)
: Container(
width: double.infinity,
height: 12.h,
decoration: BoxDecoration(
border: Border.all(
color: Colors.grey.shade300,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(2.w),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_upload_outlined,
color: AppColor.primaryColor,
size: 7.w,
),
SizedBox(height: 1.h),
Text(
"Klik untuk upload gambar",
style: TextStyle(
fontSize: (AppFontSize.small - 1.25).sp,
color: Colors.grey,
),
),
],
),
),
if (_paymentProofPath != null)
Positioned(
top: 1.w,
right: 3.5.w,
child: Material(
color: Colors.transparent,
type: MaterialType.transparency,
child: InkWell(
onTap: _removeImage,
child: Container(
padding: EdgeInsets.all(1.w),
decoration: BoxDecoration(
color: Colors.red[50],
shape: BoxShape.circle,
border: Border.all(
color: Colors.red.withOpacity(0.2),
),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 2,
offset: Offset(0, 1),
),
],
),
child: Icon(
Icons.remove,
color: Colors.red,
size: 4.w,
),
),
),
),
),
],
),
),
],
),
);
}
Widget _buildPaymentDetailSection({
required double totalOrder,
required bool isAddressSelcted,
required DeliveryFeeType deliveryFeeType,
required double deliveryFee,
required String strDistance,
required double totalDeliveryFee,
required double totalAmount,
}) {
return Container(
color: Colors.white,
padding: EdgeInsets.all(4.w),
child: Column(
children: [
_buildRowDetail(
"Subtotal Pesanan",
CurrencyFormat.formatToIdr(totalOrder, 0),
),
if (_deliveryType == DeliveryType.delivery && isAddressSelcted) ...[
if (deliveryFeeType == DeliveryFeeType.per_km) ...[
_buildRowDetail(
"Biaya Pengiriman Per Km",
CurrencyFormat.formatToIdr(deliveryFee, 0),
),
_buildRowDetail(
"Jarak alamatmu dengan toko",
"\u00B1 $strDistance",
),
],
_buildRowDetail(
"Subtotal Pengiriman",
CurrencyFormat.formatToIdr(totalDeliveryFee, 0),
),
],
const Divider(),
_buildRowDetail(
"Total Pembayaran",
CurrencyFormat.formatToIdr(totalAmount, 0),
isBold: true,
),
],
),
);
}
Widget _buildRowDetail(String label, String value, {bool isBold = false}) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 0.5.h),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
label,
style: TextStyle(
fontSize: (AppFontSize.small - (isBold ? 0 : 1.25)).sp,
fontWeight: isBold ? FontWeight.bold : FontWeight.normal,
),
),
Text(
value,
style: TextStyle(
fontSize: (AppFontSize.small - (isBold ? 0 : 1.25)).sp,
fontWeight: isBold ? FontWeight.bold : FontWeight.normal,
color: isBold ? AppColor.primaryColor : Colors.black,
),
),
],
),
);
}
Widget _buildBottomCheckoutAction({
String? deliveryFeeType,
required double deliveryFee,
required bool isLoading,
required double totalAmount,
required double totalDeliveryFee,
required double maxDeliveryRadiusKm,
}) {
return Container(
height: 8.h,
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey.shade200)),
),
child: Row(
children: [
Expanded(
child: Padding(
padding: EdgeInsets.only(left: 4.w),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"Total Pembayaran",
style: TextStyle(fontSize: AppFontSize.small.sp),
),
Text(
CurrencyFormat.formatToIdr(totalAmount, 0),
style: TextStyle(
fontSize: AppFontSize.small.sp,
fontWeight: FontWeight.bold,
color: AppColor.primaryColor,
),
),
],
),
),
),
InkWell(
onTap: isLoading
? null
: () async => await _checkout(
deliveryFeeType: deliveryFeeType,
deliveryFee: deliveryFee,
totalDeliveryFee: totalDeliveryFee,
maxDeliveryRadiusKm: maxDeliveryRadiusKm,
),
child: Container(
width: 40.w,
height: double.infinity,
color: isLoading ? Colors.grey.shade300 : AppColor.primaryColor,
alignment: Alignment.center,
child: Text(
"Buat Pesanan",
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: AppFontSize.small.sp,
),
),
),
),
],
),
);
}
}