import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:qpay/screens/confirm/confirm_controller.dart'; import 'package:qpay/screens/groups/models/group_batch_model.dart'; import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/screens/groups/controllers/batch_controller.dart'; import 'package:qpay/screens/pay/pay_controller.dart'; import 'package:qpay/widgets/app_snack_bar.dart'; import 'package:qpay/widgets/status_chip_widget.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:url_launcher/url_launcher.dart'; class BatchDetailScreen extends StatefulWidget { final String batchId; final String groupId; const BatchDetailScreen({ super.key, required this.batchId, required this.groupId, }); @override State createState() => _BatchDetailScreenState(); } class _BatchDetailScreenState extends State with TickerProviderStateMixin { late BatchController controller; ConfirmController? _confirmController; GroupBatchUpdateRequest? _cachedUpdateReq; bool _showPollButton = false; String _userId = ''; int _selectedTabIndex = 0; bool _initialLoading = true; String? _loadError; PaymentProcessor? _selectedProcessor; late AnimationController _headerAnimController; late Animation _headerSlideAnimation; late Animation _headerFadeAnimation; late AnimationController _cardAnimController; late Animation _cardSlideAnimation; late Animation _cardFadeAnimation; late AnimationController _contentAnimController; late Animation _contentSlideAnimation; late Animation _contentFadeAnimation; static const _terminalStatuses = {'SUCCESS', 'COMPLETE', 'FAILED'}; static const _successStatuses = {'SUCCESS', 'COMPLETE'}; GroupBatch? get _batch => controller.model.currentBatch; @override void initState() { super.initState(); controller = BatchController(context); controller.loadProcessors(); _headerAnimController = AnimationController( vsync: this, duration: const Duration(milliseconds: 600), ); _headerSlideAnimation = Tween(begin: const Offset(0, -0.08), end: Offset.zero).animate( CurvedAnimation( parent: _headerAnimController, curve: Curves.easeOutCubic, ), ); _headerFadeAnimation = Tween(begin: 0.0, end: 1.0).animate( CurvedAnimation(parent: _headerAnimController, curve: Curves.easeOut), ); _cardAnimController = AnimationController( vsync: this, duration: const Duration(milliseconds: 700), ); _cardSlideAnimation = Tween(begin: const Offset(0, 0.06), end: Offset.zero).animate( CurvedAnimation( parent: _cardAnimController, curve: Curves.easeOutCubic, ), ); _cardFadeAnimation = Tween(begin: 0.0, end: 1.0).animate( CurvedAnimation(parent: _cardAnimController, curve: Curves.easeOut), ); _contentAnimController = AnimationController( vsync: this, duration: const Duration(milliseconds: 800), ); _contentSlideAnimation = Tween(begin: const Offset(0, 0.08), end: Offset.zero).animate( CurvedAnimation( parent: _contentAnimController, curve: Curves.easeOutCubic, ), ); _contentFadeAnimation = Tween(begin: 0.0, end: 1.0).animate( CurvedAnimation(parent: _contentAnimController, curve: Curves.easeOut), ); _headerAnimController.forward(); Future.delayed(const Duration(milliseconds: 150), () { if (mounted) _cardAnimController.forward(); }); Future.delayed(const Duration(milliseconds: 350), () { if (mounted) _contentAnimController.forward(); }); _init(); } Future _init() async { final prefs = await SharedPreferences.getInstance(); _userId = prefs.getString('userId') ?? ''; final result = await controller.getBatch(widget.batchId, _userId); if (!mounted) return; if (result.isSuccess) { if (_batch?.id != null) { await controller.listBatchItems(_batch!.id!); } setState(() { _initialLoading = false; }); } else { setState(() { _initialLoading = false; _loadError = result.error ?? 'Failed to load batch'; }); } } @override void dispose() { _headerAnimController.dispose(); _cardAnimController.dispose(); _contentAnimController.dispose(); _confirmController?.dispose(); controller.dispose(); super.dispose(); } Future _onConfirm() async { final batch = _batch; if (batch == null) return; // Show the bottom sheet so the user can pick a processor and confirm. final processor = await _showProcessorSelectorBottomSheet(); if (processor == null) return; // user dismissed _selectedProcessor = processor; // Persist the selected processor on the batch before confirming final updateReq = GroupBatchUpdateRequest( id: batch.id, userId: _userId, paymentProcessorLabel: processor.label, paymentProcessorName: processor.name, paymentProcessorImage: processor.image, ); final updateResult = await controller.updateBatch(updateReq); if (!mounted) return; if (!updateResult.isSuccess) { _showError(updateResult.error ?? 'Failed to update processor.'); return; } // Cache the update request so _onRequest can check the processor _cachedUpdateReq = updateReq; final result = await controller.confirmBatch(batch.id!, _userId); if (!mounted) return; if (!result.isSuccess) { _showError(result.error ?? 'Confirm failed.'); } else { setState(() => _selectedProcessor = null); setState(() => _initialLoading = true); await _init(); if (!mounted) { return; } AppSnackBar.show( context, 'Batch items are now being confirmed. Tap refresh to see latest status.', ); } } /// Shows a bottom sheet that lets the user pick a payment processor. /// Returns the chosen [PaymentProcessor], or `null` if dismissed. Future _showProcessorSelectorBottomSheet() async { final processors = controller.model.processors; if (processors.isEmpty) { _showError('No payment processors available.'); return null; } final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; final currency = _batch?.currency ?? 'USD'; final value = _batch?.value?.toStringAsFixed(2) ?? '—'; PaymentProcessor? picked = _selectedProcessor; return showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (sheetContext) { return StatefulBuilder( builder: (innerContext, setSheetState) { return Container( decoration: BoxDecoration( color: isDark ? const Color(0xFF1A1A2E) : Colors.white, borderRadius: const BorderRadius.vertical( top: Radius.circular(24), ), ), padding: EdgeInsets.only( left: 20, right: 20, top: 12, bottom: MediaQuery.of(innerContext).viewInsets.bottom + 24, ), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Center( child: Container( width: 40, height: 4, margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( color: Colors.grey.shade300, borderRadius: BorderRadius.circular(2), ), ), ), Text( 'Confirm Batch', style: TextStyle( fontSize: 20, fontWeight: FontWeight.w700, color: isDark ? Colors.white : Colors.black87, ), ), const SizedBox(height: 6), Text( 'Select a payment processor to confirm this batch.', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, color: Colors.grey.shade500, ), ), const SizedBox(height: 18), Container( padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 10, ), decoration: BoxDecoration( color: theme.colorScheme.primary.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(12), ), child: Row( children: [ Icon( Icons.receipt_long_rounded, size: 18, color: theme.colorScheme.primary, ), const SizedBox(width: 10), Text( 'Batch Total', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: theme.colorScheme.primary, ), ), const Spacer(), Text( '$currency $value', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w700, color: theme.colorScheme.primary, ), ), ], ), ), const SizedBox(height: 18), Text( 'Payment Processor', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: Colors.grey.shade500, ), ), const SizedBox(height: 10), ConstrainedBox( constraints: BoxConstraints( maxHeight: MediaQuery.of(innerContext).size.height * 0.4, ), child: SingleChildScrollView( child: Column( children: processors.map((processor) { final isSelected = picked == processor; return Padding( padding: const EdgeInsets.only(bottom: 10), child: GestureDetector( onTap: () { setSheetState(() { picked = processor; }); }, child: AnimatedContainer( duration: const Duration(milliseconds: 200), curve: Curves.easeOutCubic, padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 12, ), decoration: BoxDecoration( color: isSelected ? theme.colorScheme.primary.withValues( alpha: 0.12, ) : (isDark ? Colors.white.withValues( alpha: 0.05, ) : Colors.grey.shade50), borderRadius: BorderRadius.circular(14), border: Border.all( color: isSelected ? theme.colorScheme.primary : (isDark ? Colors.white.withValues( alpha: 0.08, ) : Colors.grey.shade200), width: isSelected ? 2 : 1, ), ), child: Row( children: [ ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.asset( 'assets/${processor.image}', width: 36, height: 36, errorBuilder: (_, __, ___) => Icon( Icons.payment_rounded, size: 28, color: theme.colorScheme.primary, ), ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( processor.name, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: isDark ? Colors.white : Colors.black87, ), ), if (processor.label.isNotEmpty) Text( processor.label, style: TextStyle( fontSize: 12, color: Colors.grey.shade500, ), ), ], ), ), Icon( isSelected ? Icons.radio_button_checked : Icons.radio_button_unchecked, color: isSelected ? theme.colorScheme.primary : Colors.grey.shade400, ), ], ), ), ), ); }).toList(), ), ), ), const SizedBox(height: 8), SizedBox( width: double.infinity, child: ElevatedButton( onPressed: () { final selected = picked ?? processors.first; Navigator.of(innerContext).pop(selected); }, style: ElevatedButton.styleFrom( backgroundColor: theme.colorScheme.primary, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), elevation: 0, ), child: const Text( 'Confirm', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w700, ), ), ), ), ], ), ); }, ); }, ); } Future _onRequest() async { final batch = _batch; if (batch == null) return; final result = await controller.requestBatch(batch.id!, _userId); if (!mounted) return; if (!result.isSuccess) { _showError(result.error ?? 'Request failed.'); return; } // Check if the selected payment processor is WALLET final isWallet = _cachedUpdateReq?.paymentProcessorLabel == 'WALLET' || _selectedProcessor?.label == 'WALLET' || _batch?.paymentProcessorLabel == 'WALLET'; if (isWallet) { // Show OTP bottom sheet for WALLET processor final otp = await _showOtpBottomSheet(); if (!mounted) return; if (otp == null) { _showError('OTP verification cancelled.'); return; } // Initialize confirm controller if needed _confirmController ??= ConfirmController(context); final txn = result.data!; final otpResult = await _confirmController!.updateVelocityTransactionOtp( txn.id!, {'verificationCode': otp}, ); if (!mounted) return; if (!otpResult.isSuccess) { _showError(otpResult.error ?? 'OTP verification failed.'); return; } } // Normal flow after request (and optional OTP verification) await _init(); final txn = result.data!; final pollingStatus = txn.pollingStatus?.toUpperCase(); if (pollingStatus != null && _terminalStatuses.contains(pollingStatus)) { await _init(); if (_successStatuses.contains(pollingStatus)) { if (mounted) { AppSnackBar.showSuccess(context, 'Payment completed successfully.'); } } } else { final pollResult = await controller.pollBatch(batch.id!, _userId); if (!mounted) return; if (pollResult.isSuccess) { setState(() => _showPollButton = false); } else { setState(() => _showPollButton = true); _showError( pollResult.error ?? 'Auto-poll failed. You can retry manually.', ); } await _init(); } } /// Shows a bottom sheet for OTP input when the payment processor is WALLET. /// Returns the entered OTP string, or `null` if dismissed. Future _showOtpBottomSheet() async { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; final otpController = TextEditingController(); final formKey = GlobalKey(); return showModalBottomSheet( context: context, isScrollControlled: true, backgroundColor: Colors.transparent, builder: (sheetContext) { return Padding( padding: EdgeInsets.only( bottom: MediaQuery.of(sheetContext).viewInsets.bottom, ), child: Container( decoration: BoxDecoration( color: isDark ? const Color(0xFF1A1A2E) : Colors.white, borderRadius: const BorderRadius.vertical( top: Radius.circular(24), ), ), padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), child: Form( key: formKey, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Center( child: Container( width: 40, height: 4, margin: const EdgeInsets.only(bottom: 16), decoration: BoxDecoration( color: Colors.grey.shade300, borderRadius: BorderRadius.circular(2), ), ), ), Center( child: Container( width: 56, height: 56, decoration: BoxDecoration( color: theme.colorScheme.primary.withValues(alpha: 0.1), shape: BoxShape.circle, ), child: Icon( Icons.verified_user_rounded, size: 28, color: theme.colorScheme.primary, ), ), ), const SizedBox(height: 16), Text( 'OTP Verification', style: TextStyle( fontSize: 20, fontWeight: FontWeight.w700, color: isDark ? Colors.white : Colors.black87, ), textAlign: TextAlign.center, ), const SizedBox(height: 8), Text( 'An OTP has been sent to your registered mobile number/email. Please enter it below to proceed.', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w500, color: Colors.grey.shade500, ), textAlign: TextAlign.center, ), const SizedBox(height: 20), TextFormField( controller: otpController, keyboardType: TextInputType.number, textAlign: TextAlign.center, maxLength: 6, style: TextStyle( fontSize: 24, fontWeight: FontWeight.w700, letterSpacing: 8, color: isDark ? Colors.white : Colors.black87, ), decoration: InputDecoration( counterText: '', hintText: '------', hintStyle: TextStyle( fontSize: 24, fontWeight: FontWeight.w700, letterSpacing: 8, color: Colors.grey.shade400, ), filled: true, fillColor: isDark ? Colors.white.withValues(alpha: 0.06) : Colors.grey.shade50, border: OutlineInputBorder( borderRadius: BorderRadius.circular(14), borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(14), borderSide: BorderSide( color: theme.colorScheme.primary, width: 2, ), ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(14), borderSide: BorderSide(color: Colors.red.shade400), ), contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 14, ), ), validator: (value) { if (value == null || value.trim().isEmpty) { return 'Please enter the OTP'; } if (value.trim().length < 4) { return 'OTP must be at least 4 digits'; } return null; }, ), const SizedBox(height: 16), SizedBox( width: double.infinity, child: ElevatedButton( onPressed: () { if (formKey.currentState?.validate() ?? false) { Navigator.of( sheetContext, ).pop(otpController.text.trim()); } }, style: ElevatedButton.styleFrom( backgroundColor: theme.colorScheme.primary, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(14), ), elevation: 0, ), child: const Text( 'Submit', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w700, ), ), ), ), ], ), ), ), ); }, ); } Future _onPoll() async { final batch = _batch; if (batch == null) return; final result = await controller.pollBatch(batch.id!, _userId); if (!mounted) return; if (result.isSuccess) { setState(() => _showPollButton = false); await _init(); // refresh batch and items after poll } else { _showError(result.error ?? 'Poll failed.'); } } Future _onDownloadReport() async { final batch = _batch; if (batch == null) return; final result = await controller.downloadBatchReport(batch.id!, _userId); if (!mounted) return; if (result.isSuccess) { final fileUrl = result.data?['fileUrl'] as String?; if (fileUrl != null && fileUrl.isNotEmpty) { final uri = Uri.tryParse(fileUrl); if (uri != null && await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); } else { _showError('Could not open report URL.'); } } else { _showError('Report URL not available.'); } } else { _showError(result.error ?? 'Failed to download report.'); } } Future _onRefresh() async { setState(() { _initialLoading = true; _loadError = null; }); await _init(); } void _showError(String message) { AppSnackBar.showError(context, message); } // ────────────────────────────────────────────────────────────── // Build // ────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; return Scaffold( backgroundColor: isDark ? const Color(0xFF0D0D0D) : const Color(0xFFF8F9FA), body: ListenableBuilder( listenable: controller, builder: (context, _) { if (_initialLoading) { return const Center(child: CircularProgressIndicator()); } if (_loadError != null) { return Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon( Icons.error_outline, size: 48, color: Colors.grey.shade400, ), const SizedBox(height: 16), Text( _loadError!, textAlign: TextAlign.center, style: TextStyle( fontSize: 15, color: Colors.grey.shade600, ), ), const SizedBox(height: 16), ElevatedButton( onPressed: () { setState(() { _initialLoading = true; _loadError = null; }); _init(); }, child: const Text('Retry'), ), ], ), ), ); } final batch = _batch; if (batch == null) { return const Center(child: Text('Batch not found')); } return _buildContent(batch, theme, isDark); }, ), ); } Widget _buildContent(GroupBatch batch, ThemeData theme, bool isDark) { return CustomScrollView( physics: const BouncingScrollPhysics(), slivers: [ SliverAppBar( pinned: false, floating: true, stretch: true, stretchTriggerOffset: 80.0, expandedHeight: 60.0, collapsedHeight: 60.0, surfaceTintColor: Colors.transparent, backgroundColor: isDark ? const Color(0xFF0D0D0D) : const Color(0xFFF8F9FA), leading: context.canPop() ? IconButton( onPressed: () => context.pop(), icon: const Icon(Icons.arrow_back_rounded), color: theme.colorScheme.primary, ) : const SizedBox.shrink(), title: Text( batch.description ?? 'Batch', style: TextStyle( fontWeight: FontWeight.w700, fontSize: 20, color: isDark ? Colors.white : Colors.black87, ), ), centerTitle: true, ), SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.fromLTRB(16, 4, 16, 32), child: LayoutBuilder( builder: (context, constraints) { return Center( child: SizedBox( width: constraints.maxWidth > ResponsivePolicy.md ? ResponsivePolicy.md.toDouble() : constraints.maxWidth, child: Column( children: [ FadeTransition( opacity: _headerFadeAnimation, child: SlideTransition( position: _headerSlideAnimation, child: _buildBatchHeader(batch, theme, isDark), ), ), const SizedBox(height: 16), FadeTransition( opacity: _cardFadeAnimation, child: SlideTransition( position: _cardSlideAnimation, child: _buildSegmentedTabBar(theme, isDark), ), ), const SizedBox(height: 14), FadeTransition( opacity: _contentFadeAnimation, child: SlideTransition( position: _contentSlideAnimation, child: _buildSelectedTabContent( batch, theme, isDark, ), ), ), ], ), ), ); }, ), ), ), ], ); } // ────────────────────────────────────────────────────────────── // Refresh Button (sits next to status pill; reloads the batch) // ────────────────────────────────────────────────────────────── Widget _buildRefreshButton(ThemeData theme, bool isDark) { return Material( color: Colors.transparent, shape: const CircleBorder(), child: InkWell( customBorder: const CircleBorder(), onTap: _onRefresh, child: Tooltip( message: 'Refresh', child: Container( width: 32, height: 32, decoration: BoxDecoration( shape: BoxShape.circle, color: isDark ? Colors.white.withValues(alpha: 0.08) : Colors.grey.shade100, border: Border.all( color: isDark ? Colors.white.withValues(alpha: 0.10) : Colors.grey.shade300, ), ), child: Icon( Icons.refresh_rounded, size: 16, color: isDark ? Colors.white70 : Colors.grey.shade700, ), ), ), ), ); } // ────────────────────────────────────────────────────────────── // Dotted Divider // ────────────────────────────────────────────────────────────── Widget _buildDottedDivider(bool isDark) { return Row( children: List.generate( 40, (index) => Expanded( child: Container( height: 1.5, color: index.isEven ? (isDark ? Colors.white24 : Colors.grey.shade300) : Colors.transparent, ), ), ), ); } // ────────────────────────────────────────────────────────────── // Batch Header Card // ────────────────────────────────────────────────────────────── Widget _buildBatchHeader(GroupBatch batch, ThemeData theme, bool isDark) { final primaryColor = theme.colorScheme.primary; final currency = batch.currency ?? 'USD'; final value = batch.value; final status = batch.status ?? 'UNKNOWN'; final createdAt = batch.createdAt; final parsedDate = createdAt != null ? DateTime.tryParse(createdAt) : null; return Container( width: double.infinity, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ primaryColor.withValues(alpha: 0.10), primaryColor.withValues(alpha: 0.04), primaryColor.withValues(alpha: 0.08), ], stops: const [0.0, 0.5, 1.0], ), borderRadius: BorderRadius.circular(20), border: Border.all( color: primaryColor.withValues(alpha: 0.12), width: 1, ), boxShadow: [ BoxShadow( color: primaryColor.withValues(alpha: 0.08), blurRadius: 20, offset: const Offset(0, 6), ), ], ), padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20), child: Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: [ StatusChip(status: status), const SizedBox(width: 8), _buildRefreshButton(theme, isDark), ], ), const SizedBox(height: 12), Row( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( currency, style: TextStyle( fontSize: 20, fontWeight: FontWeight.w600, color: isDark ? Colors.white70 : Colors.grey.shade600, ), ), const SizedBox(width: 4), Text( value?.toStringAsFixed(2) ?? '—', style: TextStyle( fontSize: 42, fontWeight: FontWeight.w700, color: isDark ? Colors.white : Colors.black87, height: 1.1, ), ), ], ), const SizedBox(height: 4), if (parsedDate != null) Text( DateFormat.yMMMd().add_jm().format(parsedDate), style: TextStyle( fontSize: 13, fontWeight: FontWeight.w400, color: isDark ? Colors.white54 : Colors.grey.shade500, ), ), const SizedBox(height: 16), _buildDottedDivider(isDark), Row( mainAxisAlignment: MainAxisAlignment.center, children: [..._buildHeaderActionButtons(batch, theme)], ), ], ), ); } // ────────────────────────────────────────────────────────────── // Header Action Buttons // ────────────────────────────────────────────────────────────── List _buildHeaderActionButtons(GroupBatch batch, ThemeData theme) { final status = batch.status?.toUpperCase() ?? ''; final isActing = controller.model.isActing; final List buttons = []; if (status == 'CREATED') { buttons.add( _modernActionButton( icon: Icons.check_circle_outline_rounded, label: 'Confirm Items', color: theme.colorScheme.primary, isLoading: isActing, onPressed: _onConfirm, ), ); } else if (status == 'CONFIRMED_ALL' || status == 'REQUEST_FAILED') { buttons.add( _modernActionButton( icon: Icons.send_rounded, label: 'Push Payment Request', color: const Color(0xFF10B981), isLoading: isActing, onPressed: _onRequest, ), ); } else if (status == 'REQUESTED' || _showPollButton) { buttons.add( _modernActionButton( icon: Icons.refresh_rounded, label: 'Check Status', color: const Color(0xFFF59E0B), isLoading: isActing, onPressed: _onPoll, ), ); } if (status == 'COMPLETED') { buttons.add( _modernActionButton( icon: Icons.download_rounded, label: 'Download Report', color: const Color(0xFF6366F1), isLoading: isActing, onPressed: _onDownloadReport, ), ); } if (buttons.isEmpty) return [const SizedBox.shrink()]; return [const SizedBox(height: 14), ...buttons]; } Widget _modernActionButton({ required IconData icon, required String label, required Color color, required bool isLoading, required VoidCallback onPressed, }) { return Column( mainAxisSize: MainAxisSize.min, children: [ Container( width: 52, height: 52, decoration: BoxDecoration( shape: BoxShape.circle, gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ color.withValues(alpha: 0.15), color.withValues(alpha: 0.05), ], ), border: Border.all(color: color.withValues(alpha: 0.2), width: 1.5), boxShadow: [ BoxShadow( color: color.withValues(alpha: 0.1), blurRadius: 8, offset: const Offset(0, 2), ), ], ), child: isLoading ? Center( child: SizedBox( width: 22, height: 22, child: CircularProgressIndicator( strokeWidth: 2.5, valueColor: AlwaysStoppedAnimation(color), ), ), ) : Material( color: Colors.transparent, child: InkWell( borderRadius: BorderRadius.circular(26), onTap: onPressed, child: Center(child: Icon(icon, color: color, size: 22)), ), ), ), const SizedBox(height: 6), Text( label, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w600, color: Colors.grey.shade600, ), ), ], ); } // ────────────────────────────────────────────────────────────── // Segmented Tab Bar // ────────────────────────────────────────────────────────────── Widget _buildSegmentedTabBar(ThemeData theme, bool isDark) { final segments = ['Batch Details', 'Recipients']; return Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: isDark ? Colors.white.withValues(alpha: 0.06) : Colors.grey.shade100, borderRadius: BorderRadius.circular(16), ), child: Row( children: List.generate(segments.length, (index) { final isSelected = _selectedTabIndex == index; return Expanded( child: GestureDetector( onTap: () => setState(() => _selectedTabIndex = index), child: AnimatedContainer( duration: const Duration(milliseconds: 250), curve: Curves.easeOutCubic, padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( color: isSelected ? theme.colorScheme.primary : Colors.transparent, borderRadius: BorderRadius.circular(12), boxShadow: isSelected ? [ BoxShadow( color: theme.colorScheme.primary.withValues( alpha: 0.3, ), blurRadius: 8, offset: const Offset(0, 2), ), ] : null, ), child: Center( child: Text( segments[index], style: TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: isSelected ? Colors.white : Colors.grey.shade600, ), ), ), ), ), ); }), ), ); } // ────────────────────────────────────────────────────────────── // Tab Content // ────────────────────────────────────────────────────────────── Widget _buildSelectedTabContent( GroupBatch batch, ThemeData theme, bool isDark, ) { return AnimatedSwitcher( duration: const Duration(milliseconds: 300), switchInCurve: Curves.easeOutCubic, switchOutCurve: Curves.easeInCubic, transitionBuilder: (child, animation) { return FadeTransition( opacity: animation, child: SlideTransition( position: Tween( begin: const Offset(0, 0.04), end: Offset.zero, ).animate(animation), child: child, ), ); }, child: KeyedSubtree( key: ValueKey(_selectedTabIndex), child: _selectedTabIndex == 0 ? _buildBatchDetailsTab(batch, theme, isDark) : _buildRecipientsTab(batch, theme, isDark), ), ); } // ────────────────────────────────────────────────────────────── // Batch Details Tab // ────────────────────────────────────────────────────────────── Widget _buildBatchDetailsTab(GroupBatch batch, ThemeData theme, bool isDark) { final items = <_DetailItem>[ _DetailItem( icon: Icons.memory_rounded, label: 'Processor', value: batch.paymentProcessorName ?? '—', ), _DetailItem( icon: Icons.group_rounded, label: 'Group', value: batch.recipientGroupId ?? '—', ), ]; if (batch.description != null && batch.description!.isNotEmpty) { items.add( _DetailItem( icon: Icons.description_outlined, label: 'Description', value: batch.description!, ), ); } if (batch.userId != null) { items.add( _DetailItem( icon: Icons.person_rounded, label: 'Created by', value: batch.userId!, ), ); } return Column( children: [ _buildInfoCard( theme: theme, isDark: isDark, icon: Icons.info_outline_rounded, title: 'Batch Details', items: items, ), if (batch.count != null) ...[ const SizedBox(height: 12), _buildSummaryCard(batch, theme, isDark), ], ], ); } Widget _buildSummaryCard(GroupBatch batch, ThemeData theme, bool isDark) { final primaryColor = theme.colorScheme.primary; return Container( width: double.infinity, decoration: BoxDecoration( color: isDark ? const Color(0xFF1A1A2E) : Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all( color: isDark ? Colors.white.withValues(alpha: 0.06) : Colors.grey.shade200, ), boxShadow: [ BoxShadow( color: isDark ? Colors.black.withValues(alpha: 0.3) : Colors.black.withValues(alpha: 0.04), blurRadius: 16, offset: const Offset(0, 4), ), ], ), padding: const EdgeInsets.all(14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 36, height: 36, decoration: BoxDecoration( color: primaryColor.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(10), ), child: Icon( Icons.bar_chart_rounded, size: 18, color: primaryColor, ), ), const SizedBox(width: 12), Text( 'Summary', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: isDark ? Colors.white : Colors.black87, letterSpacing: 0.5, ), ), ], ), const SizedBox(height: 12), Container( height: 1, color: isDark ? Colors.white.withValues(alpha: 0.06) : Colors.grey.shade200, ), const SizedBox(height: 12), Row( children: [ _countBadge('Total', batch.count!, Colors.blue, isDark), const SizedBox(width: 6), _countBadge( 'Successful', batch.successfulCount ?? 0, Colors.green, isDark, ), const SizedBox(width: 6), _countBadge('Failed', batch.failedCount ?? 0, Colors.red, isDark), ], ), ], ), ); } Widget _countBadge(String label, int value, Color color, bool isDark) { return Expanded( child: Container( padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration( color: color.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(10), border: Border.all(color: color.withValues(alpha: 0.2)), ), child: Column( children: [ Text( '$value', style: TextStyle( fontSize: 20, fontWeight: FontWeight.bold, color: color, ), ), const SizedBox(height: 2), Text( label, style: TextStyle( fontSize: 11, color: color.withValues(alpha: 0.8), fontWeight: FontWeight.w500, ), ), ], ), ), ); } // ────────────────────────────────────────────────────────────── // Recipients Tab // ────────────────────────────────────────────────────────────── Widget _buildRecipientsTab(GroupBatch batch, ThemeData theme, bool isDark) { if (controller.model.isLoading) { return const Center( child: Padding( padding: EdgeInsets.all(32), child: CircularProgressIndicator(), ), ); } if (controller.model.batchItems.isEmpty) { return Padding( padding: const EdgeInsets.symmetric(vertical: 40), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.inbox_outlined, size: 48, color: Colors.grey.shade400), const SizedBox(height: 12), Text( 'No recipients yet.', style: TextStyle( fontSize: 15, color: Colors.grey.shade500, fontWeight: FontWeight.w500, ), ), ], ), ), ); } return Column( children: controller.model.batchItems .map( (item) => Padding( padding: const EdgeInsets.only(bottom: 8), child: _buildItemCard(item, batch, theme, isDark), ), ) .toList(), ); } Widget _buildItemCard( GroupBatchItem item, GroupBatch batch, ThemeData theme, bool isDark, ) { return Container( decoration: BoxDecoration( color: isDark ? const Color(0xFF1A1A2E) : Colors.white, borderRadius: BorderRadius.circular(14), border: Border.all( color: isDark ? Colors.white.withValues(alpha: 0.06) : Colors.grey.shade200, ), boxShadow: [ BoxShadow( color: isDark ? Colors.black.withValues(alpha: 0.2) : Colors.black.withValues(alpha: 0.03), blurRadius: 8, offset: const Offset(0, 2), ), ], ), padding: const EdgeInsets.all(10), child: Row( children: [ CircleAvatar( radius: 18, backgroundColor: theme.colorScheme.primary.withAlpha(25), child: Text( item.recipientName?.isNotEmpty == true ? item.recipientName![0].toUpperCase() : '?', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: theme.colorScheme.primary, ), ), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( item.recipientName ?? 'Unknown', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: isDark ? Colors.white : Colors.black87, ), ), const SizedBox(height: 2), Text( item.recipientPhone ?? '', style: TextStyle(fontSize: 12, color: Colors.grey.shade500), ), ], ), ), const SizedBox(width: 8), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( '${batch.currency ?? 'USD'} ${item.amount?.toStringAsFixed(2) ?? '—'}', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: isDark ? Colors.white : Colors.black87, ), ), const SizedBox(height: 2), StatusChip(status: item.status ?? 'PENDING'), ], ), ], ), ); } // ────────────────────────────────────────────────────────────── // Info Card (shared - mirrors receipt pattern) // ────────────────────────────────────────────────────────────── Widget _buildInfoCard({ required ThemeData theme, required bool isDark, required IconData icon, required String title, required List<_DetailItem> items, }) { return Container( width: double.infinity, decoration: BoxDecoration( color: isDark ? const Color(0xFF1A1A2E) : Colors.white, borderRadius: BorderRadius.circular(16), border: Border.all( color: isDark ? Colors.white.withValues(alpha: 0.06) : Colors.grey.shade200, width: 1, ), boxShadow: [ BoxShadow( color: isDark ? Colors.black.withValues(alpha: 0.3) : Colors.black.withValues(alpha: 0.04), blurRadius: 16, offset: const Offset(0, 4), ), BoxShadow( color: isDark ? Colors.black.withValues(alpha: 0.15) : Colors.black.withValues(alpha: 0.02), blurRadius: 4, offset: const Offset(0, 1), ), ], ), child: Padding( padding: const EdgeInsets.all(14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 36, height: 36, decoration: BoxDecoration( color: theme.colorScheme.primary.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(10), ), child: Icon(icon, size: 18, color: theme.colorScheme.primary), ), const SizedBox(width: 12), Text( title, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: isDark ? Colors.white : Colors.black87, letterSpacing: 0.5, ), ), ], ), const SizedBox(height: 12), Container( height: 1, color: isDark ? Colors.white.withValues(alpha: 0.06) : Colors.grey.shade200, ), const SizedBox(height: 12), ...items.map( (item) => Padding( padding: const EdgeInsets.only(bottom: 10), child: _buildDetailRow(item, theme, isDark), ), ), ], ), ), ); } Widget _buildDetailRow(_DetailItem item, ThemeData theme, bool isDark) { return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Icon( item.icon, size: 16, color: isDark ? Colors.white38 : Colors.grey.shade500, ), const SizedBox(width: 10), Expanded( flex: 2, child: Text( item.label, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: isDark ? Colors.white60 : Colors.grey.shade600, ), ), ), Expanded( flex: 3, child: Text( item.value, style: TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: isDark ? Colors.white : Colors.black87, ), textAlign: TextAlign.end, softWrap: true, ), ), ], ); } } // ────────────────────────────────────────────────────────────── // Data class for detail items // ────────────────────────────────────────────────────────────── class _DetailItem { final IconData icon; final String label; final String value; const _DetailItem({ required this.icon, required this.label, required this.value, }); }