Files
velocity-pay-flutter/lib/screens/groups/batch_detail_screen.dart
2026-06-11 23:48:26 +02:00

1698 lines
59 KiB
Dart

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<BatchDetailScreen> createState() => _BatchDetailScreenState();
}
class _BatchDetailScreenState extends State<BatchDetailScreen>
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<Offset> _headerSlideAnimation;
late Animation<double> _headerFadeAnimation;
late AnimationController _cardAnimController;
late Animation<Offset> _cardSlideAnimation;
late Animation<double> _cardFadeAnimation;
late AnimationController _contentAnimController;
late Animation<Offset> _contentSlideAnimation;
late Animation<double> _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<Offset>(begin: const Offset(0, -0.08), end: Offset.zero).animate(
CurvedAnimation(
parent: _headerAnimController,
curve: Curves.easeOutCubic,
),
);
_headerFadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _headerAnimController, curve: Curves.easeOut),
);
_cardAnimController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 700),
);
_cardSlideAnimation =
Tween<Offset>(begin: const Offset(0, 0.06), end: Offset.zero).animate(
CurvedAnimation(
parent: _cardAnimController,
curve: Curves.easeOutCubic,
),
);
_cardFadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(parent: _cardAnimController, curve: Curves.easeOut),
);
_contentAnimController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
_contentSlideAnimation =
Tween<Offset>(begin: const Offset(0, 0.08), end: Offset.zero).animate(
CurvedAnimation(
parent: _contentAnimController,
curve: Curves.easeOutCubic,
),
);
_contentFadeAnimation = Tween<double>(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<void> _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<void> _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<PaymentProcessor?> _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<PaymentProcessor>(
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<void> _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<String?> _showOtpBottomSheet() async {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final otpController = TextEditingController();
final formKey = GlobalKey<FormState>();
return showModalBottomSheet<String>(
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<void> _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<void> _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<void> _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<Widget> _buildHeaderActionButtons(GroupBatch batch, ThemeData theme) {
final status = batch.status?.toUpperCase() ?? '';
final isActing = controller.model.isActing;
final List<Widget> 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>(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<Offset>(
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,
});
}