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 createState() => _CheckoutScreenState(); } class _CheckoutScreenState extends ConsumerState { final _noteController = TextEditingController(); DeliveryType _deliveryType = DeliveryType.delivery; String _selectedPaymentMethod = "COD"; String? _deliveryPreference; bool _isPaymentExpanded = false; final List _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 cartItems) { return cartItems.fold(0.0, (sum, cartItem) { return sum + cartItem.sellingPrice * cartItem.quantity; }); } List _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 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 _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 _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 _removeImage() async { setState(() { _paymentProofPath = null; }); } Future _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 _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 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 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, ), ), ), ), ], ), ); } }