Files
velocity-pay-flutter/lib/screens/groups/screens/batch_detail_screen.dart

3436 lines
124 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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(() {}));
_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';
});
}
}
@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(_batch!.currency!);
}
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(_batch!.currency!);
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: 20),
_buildActionBar(theme, isDark),
const SizedBox(height: 20),
_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;
final totalCount = batch.count ?? 0;
final successCount = batch.successfulCount ?? 0;
final failedCount = batch.failedCount ?? 0;
return Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDark
? [const Color(0xFF1E1E3A), const Color(0xFF1A1A2E)]
: [Colors.white, const Color(0xFFF8F9FC)],
),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.grey.shade200,
),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.35)
: Colors.black.withValues(alpha: 0.05),
blurRadius: 20,
offset: const Offset(0, 6),
),
],
),
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Section 1: Status badge + Refresh ──
Row(
children: [
StatusChip(status: status),
const Spacer(),
_buildRefreshButton(theme, isDark),
],
),
const SizedBox(height: 20),
// ── Section 2: Hero Amount ⫶ the expressive centre-piece ──
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
currency,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: theme.colorScheme.primary,
letterSpacing: 0.5,
),
),
),
const SizedBox(width: 12),
Text(
value?.toStringAsFixed(2) ?? '',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.w800,
color: isDark ? Colors.white : Colors.black87,
height: 1.0,
letterSpacing: -1.0,
),
),
],
),
const SizedBox(height: 14),
// ── Section 3: Date display ──
if (parsedDate != null)
Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.05)
: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
),
child: Icon(
Icons.calendar_today_rounded,
size: 18,
color: isDark ? Colors.white54 : Colors.grey.shade600,
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
DateFormat.yMMMMd().format(parsedDate),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white70 : Colors.black87,
),
),
Text(
DateFormat.jm().format(parsedDate),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: isDark ? Colors.white38 : Colors.grey.shade500,
),
),
],
),
],
),
if (parsedDate != null) const SizedBox(height: 20),
// ── Section 4: Stat cards ──
if (totalCount > 0) ...[
Row(
children: [
Expanded(
child: _statCard(
icon: Icons.people_rounded,
label: 'Total',
value: totalCount,
color: const Color(0xFF3B82F6),
isDark: isDark,
),
),
const SizedBox(width: 10),
Expanded(
child: _statCard(
icon: Icons.check_circle_rounded,
label: 'Successful',
value: successCount,
color: const Color(0xFF10B981),
isDark: isDark,
),
),
const SizedBox(width: 10),
Expanded(
child: _statCard(
icon: Icons.cancel_rounded,
label: 'Failed',
value: failedCount,
color: const Color(0xFFEF4444),
isDark: isDark,
),
),
],
),
const SizedBox(height: 20),
],
// ── Section 5: Divider ──
Container(
height: 1,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: isDark
? [
Colors.white.withValues(alpha: 0.0),
Colors.white.withValues(alpha: 0.1),
Colors.white.withValues(alpha: 0.0),
]
: [
Colors.grey.shade200,
Colors.grey.shade300,
Colors.grey.shade200,
],
),
),
),
const SizedBox(height: 16),
// ── Section 6: Action buttons ──
_buildHeaderActionButtons(batch, theme),
],
),
);
}
Widget _statCard({
required IconData icon,
required String label,
required int value,
required Color color,
required bool isDark,
}) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 10),
decoration: BoxDecoration(
color: isDark
? color.withValues(alpha: 0.08)
: color.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isDark
? color.withValues(alpha: 0.18)
: color.withValues(alpha: 0.12),
),
),
child: Column(
children: [
Icon(icon, size: 20, color: color),
const SizedBox(height: 6),
Text(
'$value',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: isDark ? Colors.white : Colors.black87,
height: 1.0,
),
),
const SizedBox(height: 2),
Text(
label,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white54 : Colors.grey.shade500,
),
),
],
),
);
}
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(
_prominentActionButton(
icon: Icons.check_circle_rounded,
label: 'Confirm Batch',
subtitle: 'Verify items and prepare for payment',
color: theme.colorScheme.primary,
isLoading: isActing,
onPressed: _onConfirm,
),
);
} else if (canRequest) {
buttons.add(
_prominentPaymentButton(
image: _batch!.paymentProcessorImage ?? '',
label: 'Push Payment',
subtitle: 'Send payments to all recipients',
color: const Color(0xFF10B981),
isLoading: isActing,
onPressed: _onRequest,
),
);
} else if (canPoll) {
buttons.add(
_prominentActionButton(
icon: Icons.refresh_rounded,
label: 'Check Status',
subtitle: 'Poll for the latest payment status',
color: const Color(0xFFF59E0B),
isLoading: isActing,
onPressed: _onPoll,
),
);
}
if (canDownloadReport) {
buttons.add(
_prominentActionButton(
icon: Icons.download_rounded,
label: 'Download Report',
subtitle: 'Export batch results as CSV',
color: const Color(0xFF6366F1),
isLoading: isActing,
onPressed: _onDownloadReport,
),
);
}
if (buttons.isEmpty) return const SizedBox.shrink();
return Column(
children: buttons
.map(
(btn) =>
Padding(padding: const EdgeInsets.only(bottom: 10), child: btn),
)
.toList(),
);
}
Widget _prominentActionButton({
required IconData icon,
required String label,
required String subtitle,
required Color color,
required bool isLoading,
required VoidCallback onPressed,
}) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: isLoading ? null : onPressed,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
color.withValues(alpha: 0.12),
color.withValues(alpha: 0.06),
],
),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: color.withValues(alpha: 0.25)),
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: isLoading
? Padding(
padding: const EdgeInsets.all(10),
child: CircularProgressIndicator(
strokeWidth: 2.5,
valueColor: AlwaysStoppedAnimation<Color>(color),
),
)
: Icon(icon, size: 22, color: color),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: color,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: color.withValues(alpha: 0.7),
),
),
],
),
),
Icon(
Icons.arrow_forward_ios_rounded,
size: 16,
color: color.withValues(alpha: 0.5),
),
],
),
),
),
);
}
Widget _prominentPaymentButton({
required String image,
required String label,
required String subtitle,
required Color color,
required bool isLoading,
required VoidCallback onPressed,
}) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: isLoading ? null : onPressed,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
color.withValues(alpha: 0.12),
color.withValues(alpha: 0.06),
],
),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: color.withValues(alpha: 0.25)),
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(8),
child: isLoading
? CircularProgressIndicator(
strokeWidth: 2.5,
valueColor: AlwaysStoppedAnimation<Color>(color),
)
: ClipRRect(
borderRadius: BorderRadius.circular(6),
child: Image.asset(
'assets/$image',
width: 28,
height: 28,
errorBuilder: (_, __, ___) => Icon(
Icons.payment_rounded,
size: 22,
color: color,
),
),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: color,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: color.withValues(alpha: 0.7),
),
),
],
),
),
Icon(
Icons.arrow_forward_ios_rounded,
size: 16,
color: color.withValues(alpha: 0.5),
),
],
),
),
),
);
}
// ──────────────────────────────────────────────────────────────
// 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,
),
),
],
),
),
);
}
final totalPages = controller.model.batchItemsTotalPages;
final currentPage = controller.model.batchItemsCurrentPage;
final totalElements = controller.model.batchItemsTotalElements;
final isLoadingMore = controller.model.batchItemsIsLoadingMore;
final hasMultiplePages = totalPages > 1;
// Calculate the range of items being shown (1-based)
final pageSize = controller.model.batchItemsPageSize;
final startItem = totalElements > 0 ? (currentPage * pageSize) + 1 : 0;
final endItem = (startItem + controller.model.batchItems.length - 1).clamp(
0,
totalElements,
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (totalElements > 0)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'Showing $startItem$endItem of $totalElements 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 (isLoadingMore)
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
if (hasMultiplePages) _buildPaginationControls(batch, theme, isDark),
],
);
}
/// Builds prev/next pagination controls for batch items.
Widget _buildPaginationControls(
GroupBatch batch,
ThemeData theme,
bool isDark,
) {
final totalPages = controller.model.batchItemsTotalPages;
final currentPage = controller.model.batchItemsCurrentPage;
final isLoadingMore = controller.model.batchItemsIsLoadingMore;
final batchId = batch.id ?? '';
return Column(
children: [
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
children: [
_paginationButton(
icon: Icons.chevron_left_rounded,
tooltip: 'Previous page',
enabled: currentPage > 0 && !isLoadingMore,
onTap: () {
if (batchId.isNotEmpty) {
controller.goToBatchItemsPage(batchId, currentPage - 1);
}
},
theme: theme,
isDark: isDark,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Page ${currentPage + 1} of $totalPages',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white70 : Colors.grey.shade700,
),
),
),
_paginationButton(
icon: Icons.chevron_right_rounded,
tooltip: 'Next page',
enabled: currentPage < totalPages - 1 && !isLoadingMore,
onTap: () {
if (batchId.isNotEmpty) {
controller.goToBatchItemsPage(batchId, currentPage + 1);
}
},
theme: theme,
isDark: isDark,
),
],
),
_buildPageSizeSelector(batch, theme, isDark),
],
),
],
);
}
/// Builds a dropdown to select the number of items per page.
Widget _buildPageSizeSelector(
GroupBatch batch,
ThemeData theme,
bool isDark,
) {
final pageSize = controller.model.batchItemsPageSize;
final isLoadingMore = controller.model.batchItemsIsLoadingMore;
final batchId = batch.id ?? '';
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Items per page: ',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white54 : Colors.grey.shade500,
),
),
Container(
height: 32,
padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.10)
: Colors.grey.shade300,
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<int>(
value: pageSize,
isDense: true,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white70 : Colors.grey.shade700,
),
dropdownColor: isDark ? const Color(0xFF1A1A2E) : Colors.white,
items: const [
DropdownMenuItem(value: 10, child: Text('10')),
DropdownMenuItem(value: 20, child: Text('20')),
DropdownMenuItem(value: 50, child: Text('50')),
],
onChanged: isLoadingMore
? null
: (value) {
if (value != null && batchId.isNotEmpty) {
controller.setBatchItemsPageSize(value);
// Re-load from page 0 with the new page size
controller.searchBatchItems(
batchId,
search:
controller.model.batchItemsSearchQuery.isNotEmpty
? controller.model.batchItemsSearchQuery
: null,
status: controller.model.batchItemsStatusFilter,
account: controller.model.batchItemsRecipientAccount,
);
}
},
),
),
),
],
);
}
Widget _paginationButton({
required IconData icon,
required String tooltip,
required bool enabled,
required VoidCallback onTap,
required ThemeData theme,
required bool isDark,
}) {
return Material(
color: Colors.transparent,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: enabled ? onTap : null,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: enabled
? (isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.grey.shade100)
: (isDark
? Colors.white.withValues(alpha: 0.03)
: Colors.grey.shade50),
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: enabled
? (isDark
? Colors.white.withValues(alpha: 0.12)
: Colors.grey.shade300)
: (isDark
? Colors.white.withValues(alpha: 0.04)
: Colors.grey.shade200),
),
),
child: Icon(
icon,
size: 20,
color: enabled
? (isDark ? Colors.white70 : Colors.grey.shade700)
: (isDark ? Colors.white24 : Colors.grey.shade300),
),
),
),
);
}
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(16),
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.symmetric(vertical: 12, horizontal: 14),
child: Row(
children: [
Container(
width: 44,
height: 44,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
item.recipientName?.isNotEmpty == true
? item.recipientName![0].toUpperCase()
: '?',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: theme.colorScheme.primary,
),
),
),
),
const SizedBox(width: 14),
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: 4),
Text(item.billName ?? ''),
Row(
children: [
if (item.billProductName != null &&
item.billProductName!.isNotEmpty) ...[
Text(
item.billProductName!,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: isDark ? Colors.white54 : Colors.grey.shade500,
),
),
Text(
'',
style: TextStyle(
fontSize: 11,
color: isDark ? Colors.white24 : Colors.grey.shade400,
),
),
],
Text(
item.recipientAccount ?? '',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: isDark ? Colors.white54 : Colors.grey.shade500,
),
),
],
),
Text(item.creditAddress ?? ''),
Text(item.confirmError ?? ''),
Text(item.integrationError ?? ''),
],
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${batch.currency ?? 'USD'} ${item.amount?.toStringAsFixed(2) ?? ''}',
style: TextStyle(
fontSize: 16,
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(() {});
}
});
}
}