3436 lines
124 KiB
Dart
3436 lines
124 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(() {}));
|
||
|
||
_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(() {});
|
||
}
|
||
});
|
||
}
|
||
}
|