Files
velocity-pay-flutter/lib/screens/groups/screens/batch_detail_screen.dart
2026-06-24 18:01:34 +02:00

2963 lines
108 KiB
Dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:qpay/screens/accounts/account_provider.dart';
import 'package:qpay/screens/confirm/confirm_controller.dart';
import 'package:qpay/screens/groups/models/batch_item_update_model.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/home/home_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;
const BatchDetailScreen({super.key, required this.batchId});
@override
State<BatchDetailScreen> createState() => _BatchDetailScreenState();
}
class _BatchDetailScreenState extends State<BatchDetailScreen>
with TickerProviderStateMixin {
late BatchController controller;
ConfirmController? _confirmController;
GroupBatchUpdateRequest? _cachedUpdateReq;
bool _showPollButton = false;
String _workspaceId = '';
bool _initialLoading = true;
String? _loadError;
PaymentProcessor? _selectedProcessor;
final TextEditingController _searchController = TextEditingController();
final TextEditingController _accountController = TextEditingController();
final ScrollController _scrollController = ScrollController();
bool _editMode = false;
final Map<String, TextEditingController> _editedAmounts = {};
final Map<String, BillProvider> _editedProviders = {};
final Map<String, BillProduct?> _editedProducts = {};
late AnimationController _entryController;
late Animation<double> _entryFade;
late Animation<Offset> _entrySlide;
static const _terminalStatuses = {'SUCCESS', 'COMPLETE', 'FAILED'};
static const _successStatuses = {'SUCCESS', 'COMPLETE'};
static const _editableStatuses = {'CREATED', 'CONFIRM_FAILED', 'CONFIRMING'};
GroupBatch? get _batch => controller.model.currentBatch;
@override
void initState() {
super.initState();
controller = BatchController(context);
_entryController = AnimationController(
duration: const Duration(milliseconds: 220),
vsync: this,
);
_entryFade = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(parent: _entryController, curve: Curves.easeOut));
_entrySlide = Tween<Offset>(
begin: const Offset(0, 0.02),
end: Offset.zero,
).animate(CurvedAnimation(parent: _entryController, curve: Curves.easeOut));
_entryController.forward();
controller.addListener(() => setState(() {}));
_searchController.addListener(() => setState(() {}));
_scrollController.addListener(_onScroll);
_init();
}
Future<void> _init() async {
final prefs = await SharedPreferences.getInstance();
_workspaceId = prefs.getString('workspaceId') ?? '';
final result = await controller.getBatch(widget.batchId, _workspaceId);
if (!mounted) return;
if (result.isSuccess) {
if (_batch?.id != null) {
await controller.listBatchItems(_batch!.id!);
}
setState(() {
_initialLoading = false;
});
controller.loadProcessors(_batch!.currency);
} else {
setState(() {
_initialLoading = false;
_loadError = result.error ?? 'Failed to load batch';
});
}
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
if (_batch?.id != null && _batch!.id!.isNotEmpty) {
controller.loadNextBatchItemsPage(_batch!.id!);
}
}
}
@override
void dispose() {
_searchController.dispose();
_accountController.dispose();
_scrollController.dispose();
_entryController.dispose();
_confirmController?.dispose();
controller.dispose();
super.dispose();
}
Future<void> _onConfirm() async {
final batch = _batch;
if (batch == null) return;
// Step 1: Choose payment processor and update the batch with it before confirming
final processor = await _showProcessorSelectorBottomSheet();
if (processor == null) return; // user dismissed
_selectedProcessor = processor;
// Persist the selected payment processor on the batch before confirming
final updateReq = GroupBatchUpdateRequest(
id: batch.id,
workspaceId: _workspaceId,
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;
// Step 2: Confirm the batch
final result = await controller.confirmBatch(
batch.id!,
_workspaceId,
_selectedProcessor!.authType,
);
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: Column(
children: [
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,
),
],
),
// Row 2.5: City Wallet balance (if available for batch currency)
Builder(
builder: (context) {
final accountProvider = context
.watch<AccountProvider>();
final batchCurrency =
_batch!.currency ?? 'USD';
final cityWallet = accountProvider
.balanceForCurrency(batchCurrency);
final hasCityWallet =
cityWallet != null;
if (!hasCityWallet)
return const SizedBox.shrink();
if (processor.label != 'WALLET')
return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(
top: 8,
),
child: Row(
children: [
Icon(
Icons
.account_balance_wallet_outlined,
size: 14,
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.7),
),
const SizedBox(width: 6),
Text(
'City Wallet Balance',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.7),
),
),
const Spacer(),
Text(
'\$${cityWallet!.balance.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: Theme.of(
context,
).colorScheme.primary,
),
),
],
),
);
},
),
],
),
),
),
);
}).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!, _workspaceId);
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!, _workspaceId);
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!, _workspaceId);
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!,
_workspaceId,
);
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.');
}
}
void _toggleEditMode() async {
if (_editMode) {
// Exiting edit mode — discard pending edits
_clearEditState();
return;
}
// Entering edit mode — ensure providers are loaded
if (controller.model.providers.isEmpty) {
await controller.loadProviders();
}
if (!mounted) return;
// Initialize edit controllers with current item values
_clearEditState();
for (final item in controller.model.batchItems) {
if (item.id != null) {
_editedAmounts[item.id!] = TextEditingController(
text: item.amount?.toStringAsFixed(2) ?? '',
);
// Match provider by label from the item's providerLabel field,
// falling back to the first provider if no match is found
if (controller.model.providers.isNotEmpty) {
final matchedProvider = item.providerLabel != null
? controller.model.providers.cast<BillProvider?>().firstWhere(
(p) => p?.label == item.providerLabel,
orElse: () => null,
)
: null;
_editedProviders[item.id!] =
matchedProvider ?? controller.model.providers.first;
}
// Match product by billProductName from the item
if (_editedProviders[item.id!] != null) {
final providerId = _editedProviders[item.id!]!.id;
_ensureProductsLoaded(providerId, item.id!);
}
}
}
setState(() => _editMode = true);
}
/// Ensures products are loaded for the given provider, then tries to match
/// the item's [billProductName] to a product. Called during edit mode init.
Future<void> _ensureProductsLoaded(String providerId, String itemId) async {
final cached = controller.model.providerProducts[providerId];
if (cached != null) {
// Already cached — match now
_matchProductForItem(itemId, cached);
return;
}
// Load products for this provider
final result = await controller.loadProviderProducts(providerId);
if (!mounted || result.data == null) return;
final item = controller.model.batchItems
.where((i) => i.id == itemId)
.firstOrNull;
if (item == null) return;
_matchProductForItem(itemId, result.data!);
setState(() {});
}
void _matchProductForItem(String itemId, List<BillProduct> products) {
final item = controller.model.batchItems
.where((i) => i.id == itemId)
.firstOrNull;
if (item == null) return;
if (products.isEmpty) {
_editedProducts[itemId] = null;
return;
}
final matchedProduct =
item.billProductName != null && item.billProductName!.isNotEmpty
? products.cast<BillProduct?>().firstWhere(
(p) =>
p?.name == item.billProductName ||
p?.displayName == item.billProductName,
orElse: () => null,
)
: null;
final selected = matchedProduct ?? products.first;
_editedProducts[itemId] = selected;
// Update amount with the product's default amount
if (selected.defaultAmount > 0) {
final ctrl = _editedAmounts[itemId];
if (ctrl != null) {
ctrl.text = selected.defaultAmount.toStringAsFixed(2);
}
}
}
void _clearEditState() {
for (final controller in _editedAmounts.values) {
controller.dispose();
}
_editedAmounts.clear();
_editedProviders.clear();
_editedProducts.clear();
setState(() => _editMode = false);
}
/// Shows a bottom sheet with provider & product dropdowns (2 columns) to apply
/// to all batch items. Returns both the chosen [BillProvider] and optional [BillProduct],
/// or `null` if dismissed.
Future<({BillProvider provider, BillProduct? product})?>
_showBatchProviderSelectorBottomSheet() async {
if (controller.model.providers.isEmpty) {
await controller.loadProviders();
if (controller.model.providers.isEmpty) {
_showError('No providers available.');
return null;
}
}
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final allProviders = controller.model.providers;
// Selected provider & product state inside the sheet
BillProvider? selectedProvider;
BillProduct? selectedProduct;
bool isLoadingProducts = false;
return showModalBottomSheet<
({BillProvider provider, BillProduct? product})
>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
return StatefulBuilder(
builder: (innerContext, setSheetState) {
// Resolve products based on selected provider
final providerProducts = selectedProvider != null
? (controller.model.providerProducts[selectedProvider!.id] ??
<BillProduct>[])
: <BillProduct>[];
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(
'Apply to All Items',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 6),
Text(
'Choose provider & product for every batch item.',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.grey.shade500,
),
),
const SizedBox(height: 18),
// Two-column: Provider + Product dropdowns
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Provider dropdown
Expanded(
child: _buildBatchProviderDropdown(
providers: allProviders,
selectedProvider: selectedProvider,
isDark: isDark,
theme: theme,
onChanged: (provider) {
setSheetState(() {
selectedProvider = provider;
selectedProduct = null;
isLoadingProducts = true;
});
// Load products for the selected provider
if (!controller.model.providerProducts.containsKey(
provider.id,
)) {
controller.loadProviderProducts(provider.id).then(
(_) {
if (mounted) {
setSheetState(() {
isLoadingProducts = false;
});
}
},
);
} else {
setSheetState(() {
isLoadingProducts = false;
});
}
},
),
),
const SizedBox(width: 8),
// Product dropdown
Expanded(
child: _buildBatchProductDropdown(
products: providerProducts,
selectedProduct: selectedProduct,
isLoading: isLoadingProducts,
isDark: isDark,
theme: theme,
onChanged: (product) {
setSheetState(() {
selectedProduct = product;
});
},
),
),
],
),
const SizedBox(height: 18),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
final provider = selectedProvider ?? allProviders.first;
Navigator.of(
innerContext,
).pop((provider: provider, product: selectedProduct));
},
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(
'Apply',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
);
},
);
},
);
}
/// Builds the provider dropdown used in the batch-level bottom sheet.
Widget _buildBatchProviderDropdown({
required List<BillProvider> providers,
required BillProvider? selectedProvider,
required bool isDark,
required ThemeData theme,
required ValueChanged<BillProvider> onChanged,
}) {
return SizedBox(
height: 48,
child: DropdownButtonFormField<BillProvider>(
value: selectedProvider,
isExpanded: true,
hint: const Text('Select Provider'),
decoration: InputDecoration(
labelText: 'Provider',
labelStyle: TextStyle(fontSize: 12, color: Colors.grey.shade500),
filled: true,
fillColor: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
isDense: true,
),
items: providers.map((provider) {
return DropdownMenuItem<BillProvider>(
value: provider,
child: Text(
provider.label,
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.white : Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: (provider) {
if (provider != null) {
onChanged(provider);
}
},
),
);
}
/// Builds the product dropdown used in the batch-level bottom sheet.
Widget _buildBatchProductDropdown({
required List<BillProduct> products,
required BillProduct? selectedProduct,
required bool isLoading,
required bool isDark,
required ThemeData theme,
required ValueChanged<BillProduct> onChanged,
}) {
if (isLoading) {
return SizedBox(
height: 48,
child: Row(
children: [
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 8),
Text(
'Loading...',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
),
],
),
);
}
if (products.isEmpty) {
return SizedBox(
height: 48,
child: InputDecorator(
decoration: InputDecoration(
labelText: 'Product',
labelStyle: TextStyle(fontSize: 12, color: Colors.grey.shade500),
filled: true,
fillColor: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
isDense: true,
),
child: Text(
'No products',
style: TextStyle(fontSize: 13, color: Colors.grey.shade500),
),
),
);
}
return SizedBox(
height: 48,
child: DropdownButtonFormField<BillProduct>(
value: selectedProduct ?? products.first,
isExpanded: true,
decoration: InputDecoration(
labelText: 'Product',
labelStyle: TextStyle(fontSize: 12, color: Colors.grey.shade500),
filled: true,
fillColor: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
isDense: true,
),
items: products.map((product) {
return DropdownMenuItem<BillProduct>(
value: product,
child: Text(
product.displayName.isNotEmpty
? product.displayName
: product.name,
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.white : Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: (product) {
if (product != null) {
onChanged(product);
}
},
),
);
}
/// Shows a bottom sheet to pick a product and apply it to all batch items.
/// Loads products for the provider if needed.
/// Returns the chosen [BillProduct], or `null` if dismissed.
Future<BillProduct?> _showBatchProductSelectorBottomSheet(
BillProvider provider,
) async {
// Load products for this provider if not already cached
if (!controller.model.providerProducts.containsKey(provider.id)) {
final result = await controller.loadProviderProducts(provider.id);
if (!mounted) return null;
if (!result.isSuccess) {
_showError(result.error ?? 'Failed to load products.');
return null;
}
}
final products = controller.model.providerProducts[provider.id] ?? [];
if (products.isEmpty) {
_showError('No products available for this provider.');
return null;
}
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
BillProduct? picked;
return showModalBottomSheet<BillProduct>(
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(
'Apply Product to All Items',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 6),
Text(
'Choose a product to set for every batch item.',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.grey.shade500,
),
),
const SizedBox(height: 18),
ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(innerContext).size.height * 0.4,
),
child: SingleChildScrollView(
child: Column(
children: products.map((product) {
final isSelected = picked == product;
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: GestureDetector(
onTap: () {
setSheetState(() => picked = product);
},
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: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: theme.colorScheme.primary
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.inventory_2_rounded,
size: 20,
color: theme.colorScheme.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
Text(
product.displayName.isNotEmpty
? product.displayName
: product.name,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark
? Colors.white
: Colors.black87,
),
),
if (product.defaultAmount > 0)
Text(
'\$${product.defaultAmount.toStringAsFixed(2)}',
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: () {
Navigator.of(
innerContext,
).pop(picked ?? products.first);
},
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(
'Apply',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
);
},
);
},
);
}
Future<bool> _saveEdits() async {
if (controller.model.isActing) return false;
final batch = _batch;
if (batch == null || batch.id == null) return false;
// Check if any edited items are missing a provider label
final anyMissingProvider = controller.model.batchItems.any((item) {
if (item.id == null) return false;
final editedProvider = _editedProviders[item.id];
return editedProvider == null || editedProvider.label.isEmpty;
});
// If any items lack a provider, prompt user to choose one (and optionally a product) for all items
if (anyMissingProvider) {
final result = await _showBatchProviderSelectorBottomSheet();
if (!mounted) return false;
if (result == null) return false;
if (!mounted) return false;
final chosenProvider = result.provider;
final chosenProduct = result.product;
// Apply chosen provider to all items
for (final item in controller.model.batchItems) {
if (item.id != null) {
_editedProviders[item.id!] = chosenProvider;
if (chosenProduct != null) {
_editedProducts[item.id!] = chosenProduct;
}
}
}
// Also ensure products are loaded for the chosen provider
if (!controller.model.providerProducts.containsKey(chosenProvider.id)) {
await controller.loadProviderProducts(chosenProvider.id);
if (!mounted) return false;
}
}
final items = <GroupBatchItemUpdateRequest>[];
for (final item in controller.model.batchItems) {
if (item.id == null) continue;
final amountStr = _editedAmounts[item.id]?.text ?? '';
final parsedAmount = double.tryParse(amountStr);
final provider = _editedProviders[item.id];
final product = _editedProducts[item.id];
items.add(
GroupBatchItemUpdateRequest(
id: item.id,
amount: parsedAmount ?? item.amount,
recipientName: item.recipientName,
recipientPhone: item.recipientPhone,
providerImage: provider?.image,
providerLabel: provider?.label,
billClientId: provider?.clientId,
billName: provider?.name,
billProductName:
product?.name ??
product?.displayName ??
item.billProductName ??
'',
),
);
}
if (items.isEmpty) return false;
final updateList = GroupBatchItemUpdateList(
items: items,
workspaceId: _workspaceId,
);
final result = await controller.updateBatchItems(batch.id!, updateList);
if (!mounted) return false;
_clearEditState();
if (result.isSuccess) {
// Refresh batch items to reflect updated data
await controller.listBatchItems(batch.id!);
} else {
AppSnackBar.showError(context, result.error ?? 'Failed to update items.');
}
return true;
}
Future<void> _onRefresh() async {
setState(() {
_initialLoading = true;
_loadError = null;
});
await _init();
}
void _showError(String message) {
AppSnackBar.showError(context, message);
}
void _showFilterSheet() {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) {
return StatefulBuilder(
builder: (context, setSheetState) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'Filter Items',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
TextButton(
onPressed: () {
setSheetState(() {
controller.model.batchItemsStatusFilter = null;
controller.model.batchItemsRecipientAccount = null;
_accountController.clear();
});
},
child: const Text('Clear All'),
),
],
),
const SizedBox(height: 16),
DropdownButtonFormField<String?>(
initialValue: controller.model.batchItemsStatusFilter,
decoration: InputDecoration(
labelText: 'Status',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
items: const [
DropdownMenuItem(value: null, child: Text('All')),
DropdownMenuItem(
value: 'CONFIRMED',
child: Text('CONFIRMED'),
),
DropdownMenuItem(
value: 'INTEGRATED',
child: Text('INTEGRATED'),
),
DropdownMenuItem(value: 'FAILED', child: Text('FAILED')),
DropdownMenuItem(
value: 'SKIPPED',
child: Text('SKIPPED'),
),
],
onChanged: (v) {
setSheetState(
() => controller.model.batchItemsStatusFilter = v,
);
},
),
const SizedBox(height: 16),
TextField(
controller: _accountController,
decoration: InputDecoration(
labelText: 'Account Number',
hintText: 'Filter by account number',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Navigator.of(ctx).pop();
final batchId = _batch?.id ?? '';
if (batchId.isNotEmpty) {
controller.searchBatchItems(
batchId,
search: _searchController.text.isNotEmpty
? _searchController.text
: null,
status: controller.model.batchItemsStatusFilter,
account: _accountController.text.isNotEmpty
? _accountController.text
: null,
);
}
},
child: const Text('Apply Filters'),
),
),
const SizedBox(height: 8),
],
),
);
},
);
},
);
}
// ──────────────────────────────────────────────────────────────
// 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(
controller: _scrollController,
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: FadeTransition(
opacity: _entryFade,
child: SlideTransition(
position: _entrySlide,
child: Column(
children: [
_buildCompactHeader(batch, theme, isDark),
const SizedBox(height: 12),
_buildActionBar(theme, isDark),
const SizedBox(height: 12),
_buildItemsList(batch, theme, isDark),
],
),
),
),
),
);
},
),
),
),
],
);
}
// ──────────────────────────────────────────────────────────────
// Compact Header — concise row-based summary
// ──────────────────────────────────────────────────────────────
Widget _buildCompactHeader(GroupBatch batch, ThemeData theme, bool isDark) {
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(
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.symmetric(vertical: 14, horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Row 1: Status + Amount + Refresh
Row(
children: [
StatusChip(status: status),
const SizedBox(width: 8),
if (parsedDate != null)
Expanded(
child: Text(
DateFormat.yMMMd().add_jm().format(parsedDate),
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w400,
color: isDark ? Colors.white38 : Colors.grey.shade500,
),
),
),
_buildRefreshButton(theme, isDark),
],
),
const SizedBox(height: 6),
// Row 2: Date + Summary chips
Row(
children: [
Row(
children: [
Text(
'$currency ',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white60 : Colors.grey.shade600,
),
),
Text(
value?.toStringAsFixed(2) ?? '',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
),
),
],
),
if (batch.count != null && parsedDate != null)
const SizedBox(width: 6),
if (batch.count != null)
Text(
'',
style: TextStyle(
fontSize: 11,
color: isDark ? Colors.white24 : Colors.grey.shade400,
),
),
if (batch.count != null) const SizedBox(width: 6),
if (batch.count != null)
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
_miniChip(
label: 'Total',
value: batch.count!,
color: Colors.blue,
isDark: isDark,
),
const SizedBox(width: 4),
_miniChip(
label: 'OK',
value: batch.successfulCount ?? 0,
color: Colors.green,
isDark: isDark,
),
const SizedBox(width: 4),
_miniChip(
label: 'Fail',
value: batch.failedCount ?? 0,
color: Colors.red,
isDark: isDark,
),
],
),
),
),
],
),
// Row 3: Action buttons
const SizedBox(height: 10),
_buildHeaderActionButtons(batch, theme),
],
),
);
}
Widget _miniChip({
required String label,
required int value,
required Color color,
required bool isDark,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: color.withValues(alpha: 0.25)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'$value',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: color,
),
),
const SizedBox(width: 3),
Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: color.withValues(alpha: 0.8),
),
),
],
),
);
}
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: 30,
height: 30,
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: 15,
color: isDark ? Colors.white70 : Colors.grey.shade700,
),
),
),
),
);
}
// ──────────────────────────────────────────────────────────────
// Header Action Buttons
// ──────────────────────────────────────────────────────────────
Widget _buildHeaderActionButtons(GroupBatch batch, ThemeData theme) {
final status = batch.status?.toUpperCase() ?? '';
final isActing = controller.model.isActing;
final canConfirm = ['CREATED', 'CONFIRM_FAILED'].contains(status);
final canRequest = ['CONFIRMED_ALL', 'REQUEST_FAILED'].contains(status);
final canPoll =
['REQUESTED', 'POLL_FAILED'].contains(status) || _showPollButton;
final canDownloadReport = status == 'COMPLETED';
final List<Widget> buttons = [];
if (canConfirm) {
buttons.add(
_inlineActionButton(
icon: Icons.check_circle_outline_rounded,
label: 'Confirm',
color: theme.colorScheme.primary,
isLoading: isActing,
onPressed: _onConfirm,
),
);
} else if (canRequest) {
buttons.add(
_inlineActionButton(
icon: Icons.send_rounded,
label: 'Push Payment',
color: const Color(0xFF10B981),
isLoading: isActing,
onPressed: _onRequest,
),
);
} else if (canPoll) {
buttons.add(
_inlineActionButton(
icon: Icons.refresh_rounded,
label: 'Check Status',
color: const Color(0xFFF59E0B),
isLoading: isActing,
onPressed: _onPoll,
),
);
}
if (canDownloadReport) {
buttons.add(
_inlineActionButton(
icon: Icons.download_rounded,
label: 'Report',
color: const Color(0xFF6366F1),
isLoading: isActing,
onPressed: _onDownloadReport,
),
);
}
if (buttons.isEmpty) return const SizedBox.shrink();
return Row(
children: [
...buttons.map(
(btn) =>
Padding(padding: const EdgeInsets.only(right: 8), child: btn),
),
],
);
}
Widget _inlineActionButton({
required IconData icon,
required String label,
required Color color,
required bool isLoading,
required VoidCallback onPressed,
}) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: isLoading ? null : onPressed,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: color.withValues(alpha: 0.3)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isLoading)
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(color),
),
)
else
Icon(icon, size: 16, color: color),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: color,
),
),
],
),
),
),
);
}
// ──────────────────────────────────────────────────────────────
// Action Bar — search, filter, edit, refresh
// ──────────────────────────────────────────────────────────────
Widget _buildActionBar(ThemeData theme, bool isDark) {
final hasFilters =
controller.model.batchItemsStatusFilter != null ||
controller.model.batchItemsRecipientAccount != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: TextFormField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search by recipient name',
prefixIcon: const Icon(Icons.search, size: 20),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear, size: 18),
onPressed: () {
_searchController.clear();
final batchId = _batch?.id ?? '';
if (batchId.isNotEmpty) {
controller.searchBatchItems(
batchId,
status:
controller.model.batchItemsStatusFilter,
account: controller
.model
.batchItemsRecipientAccount,
);
}
},
)
: null,
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: theme.colorScheme.primary),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
style: const TextStyle(fontSize: 14),
textInputAction: TextInputAction.search,
onFieldSubmitted: (value) {
final batchId = _batch?.id ?? '';
if (batchId.isNotEmpty) {
controller.searchBatchItems(
batchId,
search: value.isNotEmpty ? value : null,
status: controller.model.batchItemsStatusFilter,
account: controller.model.batchItemsRecipientAccount,
);
}
},
),
),
),
if (_editMode)
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: _saveButton(theme),
),
if (!_editMode &&
_editableStatuses.contains(_batch?.status?.toUpperCase() ?? ''))
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: _actionIconButton(
icon: Icons.edit_rounded,
tooltip: 'Edit items',
isActive: false,
color: Colors.grey.shade700,
bgColor: Colors.grey.shade100,
onPressed: _toggleEditMode,
),
),
if (_editMode)
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: _actionIconButton(
icon: Icons.edit_off_rounded,
tooltip: 'Cancel item edits',
isActive: false,
color: Colors.grey.shade700,
bgColor: Colors.grey.shade100,
onPressed: _toggleEditMode,
),
),
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: _actionIconButton(
icon: Icons.filter_list,
tooltip: 'Filter items',
isActive: hasFilters,
color: hasFilters
? theme.colorScheme.primary
: Colors.grey.shade700,
bgColor: hasFilters
? theme.colorScheme.primary
: Colors.grey.shade100,
onPressed: _showFilterSheet,
),
),
],
),
if (hasFilters)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Wrap(
spacing: 6,
runSpacing: 6,
children: [
if (controller.model.batchItemsStatusFilter != null)
_activeFilterChip(
'Status: ${controller.model.batchItemsStatusFilter}',
() {
controller.model.batchItemsStatusFilter = null;
final batchId = _batch?.id ?? '';
if (batchId.isNotEmpty) {
controller.searchBatchItems(
batchId,
search: _searchController.text.isNotEmpty
? _searchController.text
: null,
account: controller.model.batchItemsRecipientAccount,
);
}
},
theme,
),
if (controller.model.batchItemsRecipientAccount != null)
_activeFilterChip(
'Account: ${controller.model.batchItemsRecipientAccount}',
() {
controller.model.batchItemsRecipientAccount = null;
_accountController.clear();
final batchId = _batch?.id ?? '';
if (batchId.isNotEmpty) {
controller.searchBatchItems(
batchId,
search: _searchController.text.isNotEmpty
? _searchController.text
: null,
status: controller.model.batchItemsStatusFilter,
);
}
},
theme,
),
],
),
),
],
);
}
Widget _saveButton(ThemeData theme) {
final isSaving = controller.model.isActing;
return Container(
decoration: BoxDecoration(
color: isSaving
? theme.colorScheme.primary.withValues(alpha: 0.1)
: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
icon: isSaving
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2.5,
valueColor: AlwaysStoppedAnimation<Color>(
theme.colorScheme.primary,
),
),
)
: Icon(Icons.save_rounded, color: Colors.grey.shade700, size: 20),
tooltip: isSaving ? 'Saving...' : 'Save',
onPressed: isSaving
? null
: () async {
final result = await _saveEdits();
if (!result) {
// Handle save failure
}
},
constraints: const BoxConstraints(minWidth: 42, minHeight: 42),
padding: EdgeInsets.zero,
),
);
}
Widget _actionIconButton({
required IconData icon,
required String tooltip,
required bool isActive,
required Color color,
required Color bgColor,
required VoidCallback onPressed,
}) {
return Container(
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
icon: Icon(icon, color: color, size: 20),
tooltip: tooltip,
onPressed: onPressed,
constraints: const BoxConstraints(minWidth: 42, minHeight: 42),
padding: EdgeInsets.zero,
),
);
}
Widget _activeFilterChip(
String label,
VoidCallback onRemove,
ThemeData theme,
) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withAlpha(20),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: theme.colorScheme.primary.withAlpha(60)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 4),
InkWell(
onTap: onRemove,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(
Icons.close,
size: 14,
color: theme.colorScheme.primary,
),
),
),
],
),
);
}
// ──────────────────────────────────────────────────────────────
// Items List
// ──────────────────────────────────────────────────────────────
Widget _buildItemsList(GroupBatch batch, ThemeData theme, bool isDark) {
if (controller.model.isLoading && controller.model.batchItems.isEmpty) {
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 items found.',
style: TextStyle(
fontSize: 15,
color: Colors.grey.shade500,
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
// Show item count
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (controller.model.batchItemsTotalElements > 0)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'${controller.model.batchItems.length} of ${controller.model.batchItemsTotalElements} items',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.grey.shade500,
),
),
),
...controller.model.batchItems.map(
(item) => Padding(
padding: const EdgeInsets.only(bottom: 8),
child: _buildItemCard(item, batch, theme, isDark),
),
),
if (controller.model.batchItemsIsLoadingMore)
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
],
);
}
Widget _buildItemCard(
GroupBatchItem item,
GroupBatch batch,
ThemeData theme,
bool isDark,
) {
if (_editMode) {
return _buildEditableItemCard(item, batch, theme, 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.recipientAccount ?? '',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
),
if (item.billName != null || item.billProductName != null)
const SizedBox(height: 4),
if (item.billName != null || item.billProductName != null)
_buildBillInfoRow(item, isDark),
if (item.creditAddress != null) Text(item.creditAddress ?? ''),
],
),
),
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'),
],
),
],
),
);
}
Widget _buildBillInfoRow(GroupBatchItem item, bool isDark) {
final parts = <InlineSpan>[];
if (item.billName != null && item.billName!.isNotEmpty) {
parts.addAll([
TextSpan(
text: item.billName,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white54 : Colors.grey.shade600,
),
),
]);
}
if (item.billProductName != null && item.billProductName!.isNotEmpty) {
if (parts.isNotEmpty) {
parts.add(
TextSpan(
text: '',
style: TextStyle(fontSize: 11, color: Colors.grey.shade400),
),
);
}
parts.add(
TextSpan(
text: item.billProductName,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w400,
color: isDark ? Colors.white38 : Colors.grey.shade500,
),
),
);
}
return RichText(text: TextSpan(children: parts));
}
Widget _buildProviderBadge(GroupBatchItem item, bool isDark) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.store_rounded, size: 11, color: Colors.grey.shade400),
const SizedBox(width: 3),
Text(
item.providerLabel ?? '',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white38 : Colors.grey.shade500,
),
),
],
);
}
/// Edit-mode card: provider dropdown in the middle + editable amount + product dropdown
Widget _buildEditableItemCard(
GroupBatchItem item,
GroupBatch batch,
ThemeData theme,
bool isDark,
) {
final providers = controller.model.providers;
final itemId = item.id ?? '';
final selectedProvider = _editedProviders[itemId];
final amountController = _editedAmounts[itemId];
// Get cached products for the selected provider
final providerProducts = selectedProvider != null
? (controller.model.providerProducts[selectedProvider.id] ??
<BillProduct>[])
: <BillProduct>[];
final selectedProduct = _editedProducts[itemId];
return Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: _editMode
? theme.colorScheme.primary.withValues(alpha: 0.3)
: (isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade200),
width: _editMode ? 1.5 : 1,
),
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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Top row: Avatar + Name + Amount input
Row(
children: [
CircleAvatar(
radius: 16,
backgroundColor: theme.colorScheme.primary.withAlpha(25),
child: Text(
item.recipientName?.isNotEmpty == true
? item.recipientName![0].toUpperCase()
: '?',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: theme.colorScheme.primary,
),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.recipientName ?? 'Unknown',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : Colors.black87,
),
),
Text(
item.recipientPhone ?? '',
style: TextStyle(
fontSize: 11,
color: Colors.grey.shade500,
),
),
],
),
),
// Editable amount field (readOnly when products exist — amount comes from product)
SizedBox(
width: 110,
height: 38,
child: TextFormField(
controller: amountController,
readOnly: providerProducts.isNotEmpty,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
),
decoration: InputDecoration(
prefixText: '${batch.currency ?? 'USD'} ',
prefixStyle: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Colors.grey.shade500,
),
filled: true,
fillColor: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: theme.colorScheme.primary),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
isDense: true,
),
),
),
],
),
const SizedBox(height: 8),
// Provider + Product dropdown row (2 columns side by side)
if (providers.isNotEmpty)
Row(
children: [
Expanded(
child: SizedBox(
height: 40,
child: DropdownButtonFormField<BillProvider>(
value: selectedProvider ?? providers.first,
isExpanded: true,
decoration: InputDecoration(
labelText: 'Provider',
labelStyle: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
),
filled: true,
fillColor: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
isDense: true,
),
items: providers.map((provider) {
return DropdownMenuItem<BillProvider>(
value: provider,
child: Text(
provider.label,
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.white : Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: (provider) {
if (provider != null && itemId.isNotEmpty) {
setState(() {
_editedProviders[itemId] = provider;
// Clear the product when provider changes
_editedProducts.remove(itemId);
});
// Load products for the new provider
_onProviderChangedInEdit(itemId, provider);
}
},
),
),
),
const SizedBox(width: 8),
Expanded(
child: _buildEditProductDropdown(
itemId: itemId,
provider: selectedProvider ?? providers.first,
isDark: isDark,
theme: theme,
),
),
],
),
// Status chip on the bottom-right
if (item.status != null)
Padding(
padding: const EdgeInsets.only(top: 6),
child: Align(
alignment: Alignment.centerRight,
child: StatusChip(status: item.status!),
),
),
],
),
);
}
/// Builds the product dropdown for an editable item card.
/// Shows a loading indicator if products are being fetched.
Widget _buildEditProductDropdown({
required String itemId,
required BillProvider provider,
required bool isDark,
required ThemeData theme,
}) {
final isLoading = controller.model.loadingProviderProducts.contains(
provider.id,
);
final products =
(controller.model.providerProducts[provider.id] ?? <BillProduct>[]);
final selectedProduct = _editedProducts[itemId];
if (isLoading) {
return SizedBox(
height: 40,
child: Row(
children: [
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 8),
Text(
'Loading products...',
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
),
],
),
);
}
if (products.isEmpty) {
return SizedBox(
height: 40,
child: InputDecorator(
decoration: InputDecoration(
labelText: 'Product',
labelStyle: TextStyle(fontSize: 12, color: Colors.grey.shade500),
filled: true,
fillColor: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
isDense: true,
),
child: Text(
'No products',
style: TextStyle(fontSize: 13, color: Colors.grey.shade500),
),
),
);
}
return SizedBox(
height: 40,
child: DropdownButtonFormField<BillProduct>(
value: selectedProduct ?? products.first,
isExpanded: true,
decoration: InputDecoration(
labelText: 'Product',
labelStyle: TextStyle(fontSize: 12, color: Colors.grey.shade500),
filled: true,
fillColor: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
isDense: true,
),
items: products.map((product) {
return DropdownMenuItem<BillProduct>(
value: product,
child: Text(
product.displayName.isNotEmpty
? product.displayName
: product.name,
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.white : Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: (product) {
if (product != null && itemId.isNotEmpty) {
setState(() {
_editedProducts[itemId] = product;
// Update amount field with product's default amount
if (product.defaultAmount > 0) {
final ctrl = _editedAmounts[itemId];
if (ctrl != null) {
ctrl.text = product.defaultAmount.toStringAsFixed(2);
}
}
});
}
},
),
);
}
/// Called when the provider changes for an item in edit mode.
/// Triggers product loading for the new provider and auto-updates amount.
void _onProviderChangedInEdit(String itemId, BillProvider provider) {
// If products are already cached, update the amount now
final cached = controller.model.providerProducts[provider.id];
if (cached != null && cached.isNotEmpty) {
final ctrl = _editedAmounts[itemId];
if (ctrl != null && cached.first.defaultAmount > 0) {
ctrl.text = cached.first.defaultAmount.toStringAsFixed(2);
}
return;
}
// Load products and update amount when they arrive
controller.loadProviderProducts(provider.id).then((result) {
if (!mounted) return;
final products = result.data;
if (products != null && products.isNotEmpty) {
final ctrl = _editedAmounts[itemId];
if (ctrl != null && products.first.defaultAmount > 0) {
ctrl.text = products.first.defaultAmount.toStringAsFixed(2);
}
setState(() {});
}
});
}
}