2963 lines
108 KiB
Dart
2963 lines
108 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:qpay/screens/accounts/account_provider.dart';
|
|
import 'package:qpay/screens/confirm/confirm_controller.dart';
|
|
import 'package:qpay/screens/groups/models/batch_item_update_model.dart';
|
|
import 'package:qpay/screens/groups/models/group_batch_model.dart';
|
|
import 'package:qpay/models/responsive_policy.dart';
|
|
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
|
|
import 'package:qpay/screens/home/home_controller.dart';
|
|
import 'package:qpay/screens/pay/pay_controller.dart';
|
|
import 'package:qpay/widgets/app_snack_bar.dart';
|
|
import 'package:qpay/widgets/status_chip_widget.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
import 'package:url_launcher/url_launcher.dart';
|
|
|
|
class BatchDetailScreen extends StatefulWidget {
|
|
final String batchId;
|
|
|
|
const BatchDetailScreen({super.key, required this.batchId});
|
|
|
|
@override
|
|
State<BatchDetailScreen> createState() => _BatchDetailScreenState();
|
|
}
|
|
|
|
class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|
with TickerProviderStateMixin {
|
|
late BatchController controller;
|
|
ConfirmController? _confirmController;
|
|
GroupBatchUpdateRequest? _cachedUpdateReq;
|
|
bool _showPollButton = false;
|
|
String _workspaceId = '';
|
|
bool _initialLoading = true;
|
|
String? _loadError;
|
|
PaymentProcessor? _selectedProcessor;
|
|
|
|
final TextEditingController _searchController = TextEditingController();
|
|
final TextEditingController _accountController = TextEditingController();
|
|
final ScrollController _scrollController = ScrollController();
|
|
|
|
bool _editMode = false;
|
|
final Map<String, TextEditingController> _editedAmounts = {};
|
|
final Map<String, BillProvider> _editedProviders = {};
|
|
final Map<String, BillProduct?> _editedProducts = {};
|
|
|
|
late AnimationController _entryController;
|
|
late Animation<double> _entryFade;
|
|
late Animation<Offset> _entrySlide;
|
|
|
|
static const _terminalStatuses = {'SUCCESS', 'COMPLETE', 'FAILED'};
|
|
static const _successStatuses = {'SUCCESS', 'COMPLETE'};
|
|
static const _editableStatuses = {'CREATED', 'CONFIRM_FAILED', 'CONFIRMING'};
|
|
|
|
GroupBatch? get _batch => controller.model.currentBatch;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
controller = BatchController(context);
|
|
|
|
_entryController = AnimationController(
|
|
duration: const Duration(milliseconds: 220),
|
|
vsync: this,
|
|
);
|
|
_entryFade = Tween<double>(
|
|
begin: 0.0,
|
|
end: 1.0,
|
|
).animate(CurvedAnimation(parent: _entryController, curve: Curves.easeOut));
|
|
_entrySlide = Tween<Offset>(
|
|
begin: const Offset(0, 0.02),
|
|
end: Offset.zero,
|
|
).animate(CurvedAnimation(parent: _entryController, curve: Curves.easeOut));
|
|
_entryController.forward();
|
|
|
|
controller.addListener(() => setState(() {}));
|
|
_searchController.addListener(() => setState(() {}));
|
|
_scrollController.addListener(_onScroll);
|
|
|
|
_init();
|
|
}
|
|
|
|
Future<void> _init() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
_workspaceId = prefs.getString('workspaceId') ?? '';
|
|
|
|
final result = await controller.getBatch(widget.batchId, _workspaceId);
|
|
if (!mounted) return;
|
|
|
|
if (result.isSuccess) {
|
|
if (_batch?.id != null) {
|
|
await controller.listBatchItems(_batch!.id!);
|
|
}
|
|
setState(() {
|
|
_initialLoading = false;
|
|
});
|
|
|
|
controller.loadProcessors(_batch!.currency);
|
|
} else {
|
|
setState(() {
|
|
_initialLoading = false;
|
|
_loadError = result.error ?? 'Failed to load batch';
|
|
});
|
|
}
|
|
}
|
|
|
|
void _onScroll() {
|
|
if (_scrollController.position.pixels >=
|
|
_scrollController.position.maxScrollExtent - 200) {
|
|
if (_batch?.id != null && _batch!.id!.isNotEmpty) {
|
|
controller.loadNextBatchItemsPage(_batch!.id!);
|
|
}
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_searchController.dispose();
|
|
_accountController.dispose();
|
|
_scrollController.dispose();
|
|
_entryController.dispose();
|
|
_confirmController?.dispose();
|
|
controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _onConfirm() async {
|
|
final batch = _batch;
|
|
if (batch == null) return;
|
|
|
|
// Step 1: Choose payment processor and update the batch with it before confirming
|
|
final processor = await _showProcessorSelectorBottomSheet();
|
|
if (processor == null) return; // user dismissed
|
|
_selectedProcessor = processor;
|
|
|
|
// Persist the selected payment processor on the batch before confirming
|
|
final updateReq = GroupBatchUpdateRequest(
|
|
id: batch.id,
|
|
workspaceId: _workspaceId,
|
|
paymentProcessorLabel: processor.label,
|
|
paymentProcessorName: processor.name,
|
|
paymentProcessorImage: processor.image,
|
|
);
|
|
final updateResult = await controller.updateBatch(updateReq);
|
|
if (!mounted) return;
|
|
if (!updateResult.isSuccess) {
|
|
_showError(updateResult.error ?? 'Failed to update processor.');
|
|
return;
|
|
}
|
|
|
|
// Cache the update request so _onRequest can check the processor
|
|
_cachedUpdateReq = updateReq;
|
|
|
|
// Step 2: Confirm the batch
|
|
final result = await controller.confirmBatch(
|
|
batch.id!,
|
|
_workspaceId,
|
|
_selectedProcessor!.authType,
|
|
);
|
|
if (!mounted) return;
|
|
if (!result.isSuccess) {
|
|
_showError(result.error ?? 'Confirm failed.');
|
|
} else {
|
|
setState(() => _selectedProcessor = null);
|
|
setState(() => _initialLoading = true);
|
|
await _init();
|
|
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
AppSnackBar.show(
|
|
context,
|
|
'Batch items are now being confirmed. Tap refresh to see latest status.',
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Shows a bottom sheet that lets the user pick a payment processor.
|
|
/// Returns the chosen [PaymentProcessor], or `null` if dismissed.
|
|
Future<PaymentProcessor?> _showProcessorSelectorBottomSheet() async {
|
|
final processors = controller.model.processors;
|
|
if (processors.isEmpty) {
|
|
_showError('No payment processors available.');
|
|
return null;
|
|
}
|
|
|
|
final theme = Theme.of(context);
|
|
final isDark = theme.brightness == Brightness.dark;
|
|
final currency = _batch?.currency ?? 'USD';
|
|
final value = _batch?.value?.toStringAsFixed(2) ?? '—';
|
|
|
|
PaymentProcessor? picked = _selectedProcessor;
|
|
|
|
return showModalBottomSheet<PaymentProcessor>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (sheetContext) {
|
|
return StatefulBuilder(
|
|
builder: (innerContext, setSheetState) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
|
|
borderRadius: const BorderRadius.vertical(
|
|
top: Radius.circular(24),
|
|
),
|
|
),
|
|
padding: EdgeInsets.only(
|
|
left: 20,
|
|
right: 20,
|
|
top: 12,
|
|
bottom: MediaQuery.of(innerContext).viewInsets.bottom + 24,
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Center(
|
|
child: Container(
|
|
width: 40,
|
|
height: 4,
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade300,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
'Confirm Batch',
|
|
style: TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w700,
|
|
color: isDark ? Colors.white : Colors.black87,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'Select a payment processor to confirm this batch.',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500,
|
|
color: Colors.grey.shade500,
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 14,
|
|
vertical: 10,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.primary.withValues(alpha: 0.08),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons.receipt_long_rounded,
|
|
size: 18,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
const SizedBox(width: 10),
|
|
Text(
|
|
'Batch Total',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
Text(
|
|
'$currency $value',
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
Text(
|
|
'Payment Processor',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: Colors.grey.shade500,
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxHeight: MediaQuery.of(innerContext).size.height * 0.4,
|
|
),
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
children: processors.map((processor) {
|
|
final isSelected = picked == processor;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
child: GestureDetector(
|
|
onTap: () {
|
|
setSheetState(() {
|
|
picked = processor;
|
|
});
|
|
},
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeOutCubic,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 14,
|
|
vertical: 12,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: isSelected
|
|
? theme.colorScheme.primary.withValues(
|
|
alpha: 0.12,
|
|
)
|
|
: (isDark
|
|
? Colors.white.withValues(
|
|
alpha: 0.05,
|
|
)
|
|
: Colors.grey.shade50),
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(
|
|
color: isSelected
|
|
? theme.colorScheme.primary
|
|
: (isDark
|
|
? Colors.white.withValues(
|
|
alpha: 0.08,
|
|
)
|
|
: Colors.grey.shade200),
|
|
width: isSelected ? 2 : 1,
|
|
),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(
|
|
8,
|
|
),
|
|
child: Image.asset(
|
|
'assets/${processor.image}',
|
|
width: 36,
|
|
height: 36,
|
|
errorBuilder: (_, __, ___) => Icon(
|
|
Icons.payment_rounded,
|
|
size: 28,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
processor.name,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: isDark
|
|
? Colors.white
|
|
: Colors.black87,
|
|
),
|
|
),
|
|
if (processor.label.isNotEmpty)
|
|
Text(
|
|
processor.label,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey.shade500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Icon(
|
|
isSelected
|
|
? Icons.radio_button_checked
|
|
: Icons.radio_button_unchecked,
|
|
color: isSelected
|
|
? theme.colorScheme.primary
|
|
: Colors.grey.shade400,
|
|
),
|
|
],
|
|
),
|
|
// Row 2.5: City Wallet balance (if available for batch currency)
|
|
Builder(
|
|
builder: (context) {
|
|
final accountProvider = context
|
|
.watch<AccountProvider>();
|
|
final batchCurrency =
|
|
_batch!.currency ?? 'USD';
|
|
final cityWallet = accountProvider
|
|
.balanceForCurrency(batchCurrency);
|
|
final hasCityWallet =
|
|
cityWallet != null;
|
|
if (!hasCityWallet)
|
|
return const SizedBox.shrink();
|
|
if (processor.label != 'WALLET')
|
|
return const SizedBox.shrink();
|
|
return Padding(
|
|
padding: const EdgeInsets.only(
|
|
top: 8,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
Icons
|
|
.account_balance_wallet_outlined,
|
|
size: 14,
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.primary
|
|
.withValues(alpha: 0.7),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
'City Wallet Balance',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
color: Theme.of(context)
|
|
.colorScheme
|
|
.primary
|
|
.withValues(alpha: 0.7),
|
|
),
|
|
),
|
|
const Spacer(),
|
|
Text(
|
|
'\$${cityWallet!.balance.toStringAsFixed(2)}',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
color: Theme.of(
|
|
context,
|
|
).colorScheme.primary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: () {
|
|
final selected = picked ?? processors.first;
|
|
Navigator.of(innerContext).pop(selected);
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: theme.colorScheme.primary,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
),
|
|
elevation: 0,
|
|
),
|
|
child: const Text(
|
|
'Confirm',
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _onRequest() async {
|
|
final batch = _batch;
|
|
if (batch == null) return;
|
|
|
|
final result = await controller.requestBatch(batch.id!, _workspaceId);
|
|
if (!mounted) return;
|
|
|
|
if (!result.isSuccess) {
|
|
_showError(result.error ?? 'Request failed.');
|
|
return;
|
|
}
|
|
|
|
// Check if the selected payment processor is WALLET
|
|
final isWallet =
|
|
_cachedUpdateReq?.paymentProcessorLabel == 'WALLET' ||
|
|
_selectedProcessor?.label == 'WALLET' ||
|
|
_batch?.paymentProcessorLabel == 'WALLET';
|
|
|
|
if (isWallet) {
|
|
// Show OTP bottom sheet for WALLET processor
|
|
final otp = await _showOtpBottomSheet();
|
|
if (!mounted) return;
|
|
if (otp == null) {
|
|
_showError('OTP verification cancelled.');
|
|
return;
|
|
}
|
|
|
|
// Initialize confirm controller if needed
|
|
_confirmController ??= ConfirmController(context);
|
|
|
|
final txn = result.data!;
|
|
final otpResult = await _confirmController!.updateVelocityTransactionOtp(
|
|
txn.id!,
|
|
{'verificationCode': otp},
|
|
);
|
|
if (!mounted) return;
|
|
|
|
if (!otpResult.isSuccess) {
|
|
_showError(otpResult.error ?? 'OTP verification failed.');
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Normal flow after request (and optional OTP verification)
|
|
await _init();
|
|
|
|
final txn = result.data!;
|
|
final pollingStatus = txn.pollingStatus?.toUpperCase();
|
|
|
|
if (pollingStatus != null && _terminalStatuses.contains(pollingStatus)) {
|
|
await _init();
|
|
if (_successStatuses.contains(pollingStatus)) {
|
|
if (mounted) {
|
|
AppSnackBar.showSuccess(context, 'Payment completed successfully.');
|
|
}
|
|
}
|
|
} else {
|
|
final pollResult = await controller.pollBatch(batch.id!, _workspaceId);
|
|
if (!mounted) return;
|
|
if (pollResult.isSuccess) {
|
|
setState(() => _showPollButton = false);
|
|
} else {
|
|
setState(() => _showPollButton = true);
|
|
_showError(
|
|
pollResult.error ?? 'Auto-poll failed. You can retry manually.',
|
|
);
|
|
}
|
|
|
|
await _init();
|
|
}
|
|
}
|
|
|
|
/// Shows a bottom sheet for OTP input when the payment processor is WALLET.
|
|
/// Returns the entered OTP string, or `null` if dismissed.
|
|
Future<String?> _showOtpBottomSheet() async {
|
|
final theme = Theme.of(context);
|
|
final isDark = theme.brightness == Brightness.dark;
|
|
final otpController = TextEditingController();
|
|
final formKey = GlobalKey<FormState>();
|
|
|
|
return showModalBottomSheet<String>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (sheetContext) {
|
|
return Padding(
|
|
padding: EdgeInsets.only(
|
|
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
|
|
),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
|
|
borderRadius: const BorderRadius.vertical(
|
|
top: Radius.circular(24),
|
|
),
|
|
),
|
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
|
child: Form(
|
|
key: formKey,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Center(
|
|
child: Container(
|
|
width: 40,
|
|
height: 4,
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade300,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
),
|
|
Center(
|
|
child: Container(
|
|
width: 56,
|
|
height: 56,
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.primary.withValues(alpha: 0.1),
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: Icon(
|
|
Icons.verified_user_rounded,
|
|
size: 28,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
'OTP Verification',
|
|
style: TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w700,
|
|
color: isDark ? Colors.white : Colors.black87,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'An OTP has been sent to your registered mobile number/email. Please enter it below to proceed.',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500,
|
|
color: Colors.grey.shade500,
|
|
),
|
|
textAlign: TextAlign.center,
|
|
),
|
|
const SizedBox(height: 20),
|
|
TextFormField(
|
|
controller: otpController,
|
|
keyboardType: TextInputType.number,
|
|
textAlign: TextAlign.center,
|
|
maxLength: 6,
|
|
style: TextStyle(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 8,
|
|
color: isDark ? Colors.white : Colors.black87,
|
|
),
|
|
decoration: InputDecoration(
|
|
counterText: '',
|
|
hintText: '------',
|
|
hintStyle: TextStyle(
|
|
fontSize: 24,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 8,
|
|
color: Colors.grey.shade400,
|
|
),
|
|
filled: true,
|
|
fillColor: isDark
|
|
? Colors.white.withValues(alpha: 0.06)
|
|
: Colors.grey.shade50,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
borderSide: BorderSide(
|
|
color: theme.colorScheme.primary,
|
|
width: 2,
|
|
),
|
|
),
|
|
errorBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
borderSide: BorderSide(color: Colors.red.shade400),
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 14,
|
|
),
|
|
),
|
|
validator: (value) {
|
|
if (value == null || value.trim().isEmpty) {
|
|
return 'Please enter the OTP';
|
|
}
|
|
if (value.trim().length < 4) {
|
|
return 'OTP must be at least 4 digits';
|
|
}
|
|
return null;
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: () {
|
|
if (formKey.currentState?.validate() ?? false) {
|
|
Navigator.of(
|
|
sheetContext,
|
|
).pop(otpController.text.trim());
|
|
}
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: theme.colorScheme.primary,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
),
|
|
elevation: 0,
|
|
),
|
|
child: const Text(
|
|
'Submit',
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _onPoll() async {
|
|
final batch = _batch;
|
|
if (batch == null) return;
|
|
final result = await controller.pollBatch(batch.id!, _workspaceId);
|
|
if (!mounted) return;
|
|
if (result.isSuccess) {
|
|
setState(() => _showPollButton = false);
|
|
await _init(); // refresh batch and items after poll
|
|
} else {
|
|
_showError(result.error ?? 'Poll failed.');
|
|
}
|
|
}
|
|
|
|
Future<void> _onDownloadReport() async {
|
|
final batch = _batch;
|
|
if (batch == null) return;
|
|
final result = await controller.downloadBatchReport(
|
|
batch.id!,
|
|
_workspaceId,
|
|
);
|
|
if (!mounted) return;
|
|
if (result.isSuccess) {
|
|
final fileUrl = result.data?['fileUrl'] as String?;
|
|
if (fileUrl != null && fileUrl.isNotEmpty) {
|
|
final uri = Uri.tryParse(fileUrl);
|
|
if (uri != null && await canLaunchUrl(uri)) {
|
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
|
} else {
|
|
_showError('Could not open report URL.');
|
|
}
|
|
} else {
|
|
_showError('Report URL not available.');
|
|
}
|
|
} else {
|
|
_showError(result.error ?? 'Failed to download report.');
|
|
}
|
|
}
|
|
|
|
void _toggleEditMode() async {
|
|
if (_editMode) {
|
|
// Exiting edit mode — discard pending edits
|
|
_clearEditState();
|
|
return;
|
|
}
|
|
|
|
// Entering edit mode — ensure providers are loaded
|
|
if (controller.model.providers.isEmpty) {
|
|
await controller.loadProviders();
|
|
}
|
|
if (!mounted) return;
|
|
|
|
// Initialize edit controllers with current item values
|
|
_clearEditState();
|
|
for (final item in controller.model.batchItems) {
|
|
if (item.id != null) {
|
|
_editedAmounts[item.id!] = TextEditingController(
|
|
text: item.amount?.toStringAsFixed(2) ?? '',
|
|
);
|
|
// Match provider by label from the item's providerLabel field,
|
|
// falling back to the first provider if no match is found
|
|
if (controller.model.providers.isNotEmpty) {
|
|
final matchedProvider = item.providerLabel != null
|
|
? controller.model.providers.cast<BillProvider?>().firstWhere(
|
|
(p) => p?.label == item.providerLabel,
|
|
orElse: () => null,
|
|
)
|
|
: null;
|
|
_editedProviders[item.id!] =
|
|
matchedProvider ?? controller.model.providers.first;
|
|
}
|
|
|
|
// Match product by billProductName from the item
|
|
if (_editedProviders[item.id!] != null) {
|
|
final providerId = _editedProviders[item.id!]!.id;
|
|
_ensureProductsLoaded(providerId, item.id!);
|
|
}
|
|
}
|
|
}
|
|
|
|
setState(() => _editMode = true);
|
|
}
|
|
|
|
/// Ensures products are loaded for the given provider, then tries to match
|
|
/// the item's [billProductName] to a product. Called during edit mode init.
|
|
Future<void> _ensureProductsLoaded(String providerId, String itemId) async {
|
|
final cached = controller.model.providerProducts[providerId];
|
|
if (cached != null) {
|
|
// Already cached — match now
|
|
_matchProductForItem(itemId, cached);
|
|
return;
|
|
}
|
|
|
|
// Load products for this provider
|
|
final result = await controller.loadProviderProducts(providerId);
|
|
if (!mounted || result.data == null) return;
|
|
|
|
final item = controller.model.batchItems
|
|
.where((i) => i.id == itemId)
|
|
.firstOrNull;
|
|
if (item == null) return;
|
|
|
|
_matchProductForItem(itemId, result.data!);
|
|
setState(() {});
|
|
}
|
|
|
|
void _matchProductForItem(String itemId, List<BillProduct> products) {
|
|
final item = controller.model.batchItems
|
|
.where((i) => i.id == itemId)
|
|
.firstOrNull;
|
|
if (item == null) return;
|
|
|
|
if (products.isEmpty) {
|
|
_editedProducts[itemId] = null;
|
|
return;
|
|
}
|
|
|
|
final matchedProduct =
|
|
item.billProductName != null && item.billProductName!.isNotEmpty
|
|
? products.cast<BillProduct?>().firstWhere(
|
|
(p) =>
|
|
p?.name == item.billProductName ||
|
|
p?.displayName == item.billProductName,
|
|
orElse: () => null,
|
|
)
|
|
: null;
|
|
final selected = matchedProduct ?? products.first;
|
|
_editedProducts[itemId] = selected;
|
|
|
|
// Update amount with the product's default amount
|
|
if (selected.defaultAmount > 0) {
|
|
final ctrl = _editedAmounts[itemId];
|
|
if (ctrl != null) {
|
|
ctrl.text = selected.defaultAmount.toStringAsFixed(2);
|
|
}
|
|
}
|
|
}
|
|
|
|
void _clearEditState() {
|
|
for (final controller in _editedAmounts.values) {
|
|
controller.dispose();
|
|
}
|
|
_editedAmounts.clear();
|
|
_editedProviders.clear();
|
|
_editedProducts.clear();
|
|
setState(() => _editMode = false);
|
|
}
|
|
|
|
/// Shows a bottom sheet with provider & product dropdowns (2 columns) to apply
|
|
/// to all batch items. Returns both the chosen [BillProvider] and optional [BillProduct],
|
|
/// or `null` if dismissed.
|
|
Future<({BillProvider provider, BillProduct? product})?>
|
|
_showBatchProviderSelectorBottomSheet() async {
|
|
if (controller.model.providers.isEmpty) {
|
|
await controller.loadProviders();
|
|
if (controller.model.providers.isEmpty) {
|
|
_showError('No providers available.');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
final theme = Theme.of(context);
|
|
final isDark = theme.brightness == Brightness.dark;
|
|
final allProviders = controller.model.providers;
|
|
|
|
// Selected provider & product state inside the sheet
|
|
BillProvider? selectedProvider;
|
|
BillProduct? selectedProduct;
|
|
bool isLoadingProducts = false;
|
|
|
|
return showModalBottomSheet<
|
|
({BillProvider provider, BillProduct? product})
|
|
>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (sheetContext) {
|
|
return StatefulBuilder(
|
|
builder: (innerContext, setSheetState) {
|
|
// Resolve products based on selected provider
|
|
final providerProducts = selectedProvider != null
|
|
? (controller.model.providerProducts[selectedProvider!.id] ??
|
|
<BillProduct>[])
|
|
: <BillProduct>[];
|
|
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
|
|
borderRadius: const BorderRadius.vertical(
|
|
top: Radius.circular(24),
|
|
),
|
|
),
|
|
padding: EdgeInsets.only(
|
|
left: 20,
|
|
right: 20,
|
|
top: 12,
|
|
bottom: MediaQuery.of(innerContext).viewInsets.bottom + 24,
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Center(
|
|
child: Container(
|
|
width: 40,
|
|
height: 4,
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade300,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
'Apply to All Items',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w700,
|
|
color: isDark ? Colors.white : Colors.black87,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'Choose provider & product for every batch item.',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500,
|
|
color: Colors.grey.shade500,
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
// Two-column: Provider + Product dropdowns
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Provider dropdown
|
|
Expanded(
|
|
child: _buildBatchProviderDropdown(
|
|
providers: allProviders,
|
|
selectedProvider: selectedProvider,
|
|
isDark: isDark,
|
|
theme: theme,
|
|
onChanged: (provider) {
|
|
setSheetState(() {
|
|
selectedProvider = provider;
|
|
selectedProduct = null;
|
|
isLoadingProducts = true;
|
|
});
|
|
// Load products for the selected provider
|
|
if (!controller.model.providerProducts.containsKey(
|
|
provider.id,
|
|
)) {
|
|
controller.loadProviderProducts(provider.id).then(
|
|
(_) {
|
|
if (mounted) {
|
|
setSheetState(() {
|
|
isLoadingProducts = false;
|
|
});
|
|
}
|
|
},
|
|
);
|
|
} else {
|
|
setSheetState(() {
|
|
isLoadingProducts = false;
|
|
});
|
|
}
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
// Product dropdown
|
|
Expanded(
|
|
child: _buildBatchProductDropdown(
|
|
products: providerProducts,
|
|
selectedProduct: selectedProduct,
|
|
isLoading: isLoadingProducts,
|
|
isDark: isDark,
|
|
theme: theme,
|
|
onChanged: (product) {
|
|
setSheetState(() {
|
|
selectedProduct = product;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 18),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: () {
|
|
final provider = selectedProvider ?? allProviders.first;
|
|
Navigator.of(
|
|
innerContext,
|
|
).pop((provider: provider, product: selectedProduct));
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: theme.colorScheme.primary,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
),
|
|
elevation: 0,
|
|
),
|
|
child: const Text(
|
|
'Apply',
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
/// Builds the provider dropdown used in the batch-level bottom sheet.
|
|
Widget _buildBatchProviderDropdown({
|
|
required List<BillProvider> providers,
|
|
required BillProvider? selectedProvider,
|
|
required bool isDark,
|
|
required ThemeData theme,
|
|
required ValueChanged<BillProvider> onChanged,
|
|
}) {
|
|
return SizedBox(
|
|
height: 48,
|
|
child: DropdownButtonFormField<BillProvider>(
|
|
value: selectedProvider,
|
|
isExpanded: true,
|
|
hint: const Text('Select Provider'),
|
|
decoration: InputDecoration(
|
|
labelText: 'Provider',
|
|
labelStyle: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
|
filled: true,
|
|
fillColor: isDark
|
|
? Colors.white.withValues(alpha: 0.06)
|
|
: Colors.grey.shade50,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 6,
|
|
),
|
|
isDense: true,
|
|
),
|
|
items: providers.map((provider) {
|
|
return DropdownMenuItem<BillProvider>(
|
|
value: provider,
|
|
child: Text(
|
|
provider.label,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: isDark ? Colors.white : Colors.black87,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
);
|
|
}).toList(),
|
|
onChanged: (provider) {
|
|
if (provider != null) {
|
|
onChanged(provider);
|
|
}
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Builds the product dropdown used in the batch-level bottom sheet.
|
|
Widget _buildBatchProductDropdown({
|
|
required List<BillProduct> products,
|
|
required BillProduct? selectedProduct,
|
|
required bool isLoading,
|
|
required bool isDark,
|
|
required ThemeData theme,
|
|
required ValueChanged<BillProduct> onChanged,
|
|
}) {
|
|
if (isLoading) {
|
|
return SizedBox(
|
|
height: 48,
|
|
child: Row(
|
|
children: [
|
|
const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Loading...',
|
|
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
if (products.isEmpty) {
|
|
return SizedBox(
|
|
height: 48,
|
|
child: InputDecorator(
|
|
decoration: InputDecoration(
|
|
labelText: 'Product',
|
|
labelStyle: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
|
filled: true,
|
|
fillColor: isDark
|
|
? Colors.white.withValues(alpha: 0.06)
|
|
: Colors.grey.shade50,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 6,
|
|
),
|
|
isDense: true,
|
|
),
|
|
child: Text(
|
|
'No products',
|
|
style: TextStyle(fontSize: 13, color: Colors.grey.shade500),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return SizedBox(
|
|
height: 48,
|
|
child: DropdownButtonFormField<BillProduct>(
|
|
value: selectedProduct ?? products.first,
|
|
isExpanded: true,
|
|
decoration: InputDecoration(
|
|
labelText: 'Product',
|
|
labelStyle: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
|
filled: true,
|
|
fillColor: isDark
|
|
? Colors.white.withValues(alpha: 0.06)
|
|
: Colors.grey.shade50,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 6,
|
|
),
|
|
isDense: true,
|
|
),
|
|
items: products.map((product) {
|
|
return DropdownMenuItem<BillProduct>(
|
|
value: product,
|
|
child: Text(
|
|
product.displayName.isNotEmpty
|
|
? product.displayName
|
|
: product.name,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: isDark ? Colors.white : Colors.black87,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
);
|
|
}).toList(),
|
|
onChanged: (product) {
|
|
if (product != null) {
|
|
onChanged(product);
|
|
}
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Shows a bottom sheet to pick a product and apply it to all batch items.
|
|
/// Loads products for the provider if needed.
|
|
/// Returns the chosen [BillProduct], or `null` if dismissed.
|
|
Future<BillProduct?> _showBatchProductSelectorBottomSheet(
|
|
BillProvider provider,
|
|
) async {
|
|
// Load products for this provider if not already cached
|
|
if (!controller.model.providerProducts.containsKey(provider.id)) {
|
|
final result = await controller.loadProviderProducts(provider.id);
|
|
if (!mounted) return null;
|
|
if (!result.isSuccess) {
|
|
_showError(result.error ?? 'Failed to load products.');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
final products = controller.model.providerProducts[provider.id] ?? [];
|
|
if (products.isEmpty) {
|
|
_showError('No products available for this provider.');
|
|
return null;
|
|
}
|
|
|
|
final theme = Theme.of(context);
|
|
final isDark = theme.brightness == Brightness.dark;
|
|
BillProduct? picked;
|
|
|
|
return showModalBottomSheet<BillProduct>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (sheetContext) {
|
|
return StatefulBuilder(
|
|
builder: (innerContext, setSheetState) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
|
|
borderRadius: const BorderRadius.vertical(
|
|
top: Radius.circular(24),
|
|
),
|
|
),
|
|
padding: EdgeInsets.only(
|
|
left: 20,
|
|
right: 20,
|
|
top: 12,
|
|
bottom: MediaQuery.of(innerContext).viewInsets.bottom + 24,
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Center(
|
|
child: Container(
|
|
width: 40,
|
|
height: 4,
|
|
margin: const EdgeInsets.only(bottom: 16),
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey.shade300,
|
|
borderRadius: BorderRadius.circular(2),
|
|
),
|
|
),
|
|
),
|
|
Text(
|
|
'Apply Product to All Items',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w700,
|
|
color: isDark ? Colors.white : Colors.black87,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'Choose a product to set for every batch item.',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500,
|
|
color: Colors.grey.shade500,
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
ConstrainedBox(
|
|
constraints: BoxConstraints(
|
|
maxHeight: MediaQuery.of(innerContext).size.height * 0.4,
|
|
),
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
children: products.map((product) {
|
|
final isSelected = picked == product;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
child: GestureDetector(
|
|
onTap: () {
|
|
setSheetState(() => picked = product);
|
|
},
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
curve: Curves.easeOutCubic,
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 14,
|
|
vertical: 12,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: isSelected
|
|
? theme.colorScheme.primary.withValues(
|
|
alpha: 0.12,
|
|
)
|
|
: (isDark
|
|
? Colors.white.withValues(
|
|
alpha: 0.05,
|
|
)
|
|
: Colors.grey.shade50),
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(
|
|
color: isSelected
|
|
? theme.colorScheme.primary
|
|
: (isDark
|
|
? Colors.white.withValues(
|
|
alpha: 0.08,
|
|
)
|
|
: Colors.grey.shade200),
|
|
width: isSelected ? 2 : 1,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 36,
|
|
height: 36,
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.primary
|
|
.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Icon(
|
|
Icons.inventory_2_rounded,
|
|
size: 20,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
product.displayName.isNotEmpty
|
|
? product.displayName
|
|
: product.name,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: isDark
|
|
? Colors.white
|
|
: Colors.black87,
|
|
),
|
|
),
|
|
if (product.defaultAmount > 0)
|
|
Text(
|
|
'\$${product.defaultAmount.toStringAsFixed(2)}',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey.shade500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Icon(
|
|
isSelected
|
|
? Icons.radio_button_checked
|
|
: Icons.radio_button_unchecked,
|
|
color: isSelected
|
|
? theme.colorScheme.primary
|
|
: Colors.grey.shade400,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}).toList(),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.of(
|
|
innerContext,
|
|
).pop(picked ?? products.first);
|
|
},
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: theme.colorScheme.primary,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(14),
|
|
),
|
|
elevation: 0,
|
|
),
|
|
child: const Text(
|
|
'Apply',
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<bool> _saveEdits() async {
|
|
if (controller.model.isActing) return false;
|
|
final batch = _batch;
|
|
if (batch == null || batch.id == null) return false;
|
|
|
|
// Check if any edited items are missing a provider label
|
|
final anyMissingProvider = controller.model.batchItems.any((item) {
|
|
if (item.id == null) return false;
|
|
final editedProvider = _editedProviders[item.id];
|
|
return editedProvider == null || editedProvider.label.isEmpty;
|
|
});
|
|
|
|
// If any items lack a provider, prompt user to choose one (and optionally a product) for all items
|
|
if (anyMissingProvider) {
|
|
final result = await _showBatchProviderSelectorBottomSheet();
|
|
if (!mounted) return false;
|
|
if (result == null) return false;
|
|
if (!mounted) return false;
|
|
|
|
final chosenProvider = result.provider;
|
|
final chosenProduct = result.product;
|
|
|
|
// Apply chosen provider to all items
|
|
for (final item in controller.model.batchItems) {
|
|
if (item.id != null) {
|
|
_editedProviders[item.id!] = chosenProvider;
|
|
if (chosenProduct != null) {
|
|
_editedProducts[item.id!] = chosenProduct;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Also ensure products are loaded for the chosen provider
|
|
if (!controller.model.providerProducts.containsKey(chosenProvider.id)) {
|
|
await controller.loadProviderProducts(chosenProvider.id);
|
|
if (!mounted) return false;
|
|
}
|
|
}
|
|
|
|
final items = <GroupBatchItemUpdateRequest>[];
|
|
for (final item in controller.model.batchItems) {
|
|
if (item.id == null) continue;
|
|
|
|
final amountStr = _editedAmounts[item.id]?.text ?? '';
|
|
final parsedAmount = double.tryParse(amountStr);
|
|
final provider = _editedProviders[item.id];
|
|
final product = _editedProducts[item.id];
|
|
|
|
items.add(
|
|
GroupBatchItemUpdateRequest(
|
|
id: item.id,
|
|
amount: parsedAmount ?? item.amount,
|
|
recipientName: item.recipientName,
|
|
recipientPhone: item.recipientPhone,
|
|
providerImage: provider?.image,
|
|
providerLabel: provider?.label,
|
|
billClientId: provider?.clientId,
|
|
billName: provider?.name,
|
|
billProductName:
|
|
product?.name ??
|
|
product?.displayName ??
|
|
item.billProductName ??
|
|
'',
|
|
),
|
|
);
|
|
}
|
|
|
|
if (items.isEmpty) return false;
|
|
|
|
final updateList = GroupBatchItemUpdateList(
|
|
items: items,
|
|
workspaceId: _workspaceId,
|
|
);
|
|
|
|
final result = await controller.updateBatchItems(batch.id!, updateList);
|
|
if (!mounted) return false;
|
|
|
|
_clearEditState();
|
|
|
|
if (result.isSuccess) {
|
|
// Refresh batch items to reflect updated data
|
|
await controller.listBatchItems(batch.id!);
|
|
} else {
|
|
AppSnackBar.showError(context, result.error ?? 'Failed to update items.');
|
|
}
|
|
return true;
|
|
}
|
|
|
|
Future<void> _onRefresh() async {
|
|
setState(() {
|
|
_initialLoading = true;
|
|
_loadError = null;
|
|
});
|
|
await _init();
|
|
}
|
|
|
|
void _showError(String message) {
|
|
AppSnackBar.showError(context, message);
|
|
}
|
|
|
|
void _showFilterSheet() {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
builder: (ctx) {
|
|
return StatefulBuilder(
|
|
builder: (context, setSheetState) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Text(
|
|
'Filter Items',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
const Spacer(),
|
|
TextButton(
|
|
onPressed: () {
|
|
setSheetState(() {
|
|
controller.model.batchItemsStatusFilter = null;
|
|
controller.model.batchItemsRecipientAccount = null;
|
|
_accountController.clear();
|
|
});
|
|
},
|
|
child: const Text('Clear All'),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
DropdownButtonFormField<String?>(
|
|
initialValue: controller.model.batchItemsStatusFilter,
|
|
decoration: InputDecoration(
|
|
labelText: 'Status',
|
|
filled: true,
|
|
fillColor: Colors.grey.shade100,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 14,
|
|
),
|
|
),
|
|
items: const [
|
|
DropdownMenuItem(value: null, child: Text('All')),
|
|
DropdownMenuItem(
|
|
value: 'CONFIRMED',
|
|
child: Text('CONFIRMED'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 'INTEGRATED',
|
|
child: Text('INTEGRATED'),
|
|
),
|
|
DropdownMenuItem(value: 'FAILED', child: Text('FAILED')),
|
|
DropdownMenuItem(
|
|
value: 'SKIPPED',
|
|
child: Text('SKIPPED'),
|
|
),
|
|
],
|
|
onChanged: (v) {
|
|
setSheetState(
|
|
() => controller.model.batchItemsStatusFilter = v,
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
TextField(
|
|
controller: _accountController,
|
|
decoration: InputDecoration(
|
|
labelText: 'Account Number',
|
|
hintText: 'Filter by account number',
|
|
filled: true,
|
|
fillColor: Colors.grey.shade100,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 16,
|
|
vertical: 14,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: () {
|
|
Navigator.of(ctx).pop();
|
|
final batchId = _batch?.id ?? '';
|
|
if (batchId.isNotEmpty) {
|
|
controller.searchBatchItems(
|
|
batchId,
|
|
search: _searchController.text.isNotEmpty
|
|
? _searchController.text
|
|
: null,
|
|
status: controller.model.batchItemsStatusFilter,
|
|
account: _accountController.text.isNotEmpty
|
|
? _accountController.text
|
|
: null,
|
|
);
|
|
}
|
|
},
|
|
child: const Text('Apply Filters'),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// Build
|
|
// ──────────────────────────────────────────────────────────────
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
final isDark = theme.brightness == Brightness.dark;
|
|
|
|
return Scaffold(
|
|
backgroundColor: isDark
|
|
? const Color(0xFF0D0D0D)
|
|
: const Color(0xFFF8F9FA),
|
|
body: ListenableBuilder(
|
|
listenable: controller,
|
|
builder: (context, _) {
|
|
if (_initialLoading) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (_loadError != null) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(
|
|
Icons.error_outline,
|
|
size: 48,
|
|
color: Colors.grey.shade400,
|
|
),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
_loadError!,
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
color: Colors.grey.shade600,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
setState(() {
|
|
_initialLoading = true;
|
|
_loadError = null;
|
|
});
|
|
_init();
|
|
},
|
|
child: const Text('Retry'),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final batch = _batch;
|
|
if (batch == null) {
|
|
return const Center(child: Text('Batch not found'));
|
|
}
|
|
|
|
return _buildContent(batch, theme, isDark);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildContent(GroupBatch batch, ThemeData theme, bool isDark) {
|
|
return CustomScrollView(
|
|
controller: _scrollController,
|
|
physics: const BouncingScrollPhysics(),
|
|
slivers: [
|
|
SliverAppBar(
|
|
pinned: false,
|
|
floating: true,
|
|
stretch: true,
|
|
stretchTriggerOffset: 80.0,
|
|
expandedHeight: 60.0,
|
|
collapsedHeight: 60.0,
|
|
surfaceTintColor: Colors.transparent,
|
|
backgroundColor: isDark
|
|
? const Color(0xFF0D0D0D)
|
|
: const Color(0xFFF8F9FA),
|
|
leading: context.canPop()
|
|
? IconButton(
|
|
onPressed: () => context.pop(),
|
|
icon: const Icon(Icons.arrow_back_rounded),
|
|
color: theme.colorScheme.primary,
|
|
)
|
|
: const SizedBox.shrink(),
|
|
title: Text(
|
|
batch.description ?? 'Batch',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 20,
|
|
color: isDark ? Colors.white : Colors.black87,
|
|
),
|
|
),
|
|
centerTitle: true,
|
|
),
|
|
SliverToBoxAdapter(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 32),
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
return Center(
|
|
child: SizedBox(
|
|
width: constraints.maxWidth > ResponsivePolicy.md
|
|
? ResponsivePolicy.md.toDouble()
|
|
: constraints.maxWidth,
|
|
child: FadeTransition(
|
|
opacity: _entryFade,
|
|
child: SlideTransition(
|
|
position: _entrySlide,
|
|
child: Column(
|
|
children: [
|
|
_buildCompactHeader(batch, theme, isDark),
|
|
const SizedBox(height: 12),
|
|
_buildActionBar(theme, isDark),
|
|
const SizedBox(height: 12),
|
|
_buildItemsList(batch, theme, isDark),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// Compact Header — concise row-based summary
|
|
// ──────────────────────────────────────────────────────────────
|
|
Widget _buildCompactHeader(GroupBatch batch, ThemeData theme, bool isDark) {
|
|
final currency = batch.currency ?? 'USD';
|
|
final value = batch.value;
|
|
final status = batch.status ?? 'UNKNOWN';
|
|
final createdAt = batch.createdAt;
|
|
final parsedDate = createdAt != null ? DateTime.tryParse(createdAt) : null;
|
|
|
|
return Container(
|
|
width: double.infinity,
|
|
decoration: BoxDecoration(
|
|
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(
|
|
color: isDark
|
|
? Colors.white.withValues(alpha: 0.06)
|
|
: Colors.grey.shade200,
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: isDark
|
|
? Colors.black.withValues(alpha: 0.3)
|
|
: Colors.black.withValues(alpha: 0.04),
|
|
blurRadius: 16,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Row 1: Status + Amount + Refresh
|
|
Row(
|
|
children: [
|
|
StatusChip(status: status),
|
|
const SizedBox(width: 8),
|
|
if (parsedDate != null)
|
|
Expanded(
|
|
child: Text(
|
|
DateFormat.yMMMd().add_jm().format(parsedDate),
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w400,
|
|
color: isDark ? Colors.white38 : Colors.grey.shade500,
|
|
),
|
|
),
|
|
),
|
|
_buildRefreshButton(theme, isDark),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
// Row 2: Date + Summary chips
|
|
Row(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
'$currency ',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w500,
|
|
color: isDark ? Colors.white60 : Colors.grey.shade600,
|
|
),
|
|
),
|
|
Text(
|
|
value?.toStringAsFixed(2) ?? '—',
|
|
style: TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w700,
|
|
color: isDark ? Colors.white : Colors.black87,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (batch.count != null && parsedDate != null)
|
|
const SizedBox(width: 6),
|
|
if (batch.count != null)
|
|
Text(
|
|
'•',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: isDark ? Colors.white24 : Colors.grey.shade400,
|
|
),
|
|
),
|
|
if (batch.count != null) const SizedBox(width: 6),
|
|
if (batch.count != null)
|
|
Expanded(
|
|
child: SingleChildScrollView(
|
|
scrollDirection: Axis.horizontal,
|
|
child: Row(
|
|
children: [
|
|
_miniChip(
|
|
label: 'Total',
|
|
value: batch.count!,
|
|
color: Colors.blue,
|
|
isDark: isDark,
|
|
),
|
|
const SizedBox(width: 4),
|
|
_miniChip(
|
|
label: 'OK',
|
|
value: batch.successfulCount ?? 0,
|
|
color: Colors.green,
|
|
isDark: isDark,
|
|
),
|
|
const SizedBox(width: 4),
|
|
_miniChip(
|
|
label: 'Fail',
|
|
value: batch.failedCount ?? 0,
|
|
color: Colors.red,
|
|
isDark: isDark,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
// Row 3: Action buttons
|
|
const SizedBox(height: 10),
|
|
_buildHeaderActionButtons(batch, theme),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _miniChip({
|
|
required String label,
|
|
required int value,
|
|
required Color color,
|
|
required bool isDark,
|
|
}) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(6),
|
|
border: Border.all(color: color.withValues(alpha: 0.25)),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
'$value',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
color: color,
|
|
),
|
|
),
|
|
const SizedBox(width: 3),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w500,
|
|
color: color.withValues(alpha: 0.8),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildRefreshButton(ThemeData theme, bool isDark) {
|
|
return Material(
|
|
color: Colors.transparent,
|
|
shape: const CircleBorder(),
|
|
child: InkWell(
|
|
customBorder: const CircleBorder(),
|
|
onTap: _onRefresh,
|
|
child: Tooltip(
|
|
message: 'Refresh',
|
|
child: Container(
|
|
width: 30,
|
|
height: 30,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: isDark
|
|
? Colors.white.withValues(alpha: 0.08)
|
|
: Colors.grey.shade100,
|
|
border: Border.all(
|
|
color: isDark
|
|
? Colors.white.withValues(alpha: 0.10)
|
|
: Colors.grey.shade300,
|
|
),
|
|
),
|
|
child: Icon(
|
|
Icons.refresh_rounded,
|
|
size: 15,
|
|
color: isDark ? Colors.white70 : Colors.grey.shade700,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// Header Action Buttons
|
|
// ──────────────────────────────────────────────────────────────
|
|
Widget _buildHeaderActionButtons(GroupBatch batch, ThemeData theme) {
|
|
final status = batch.status?.toUpperCase() ?? '';
|
|
final isActing = controller.model.isActing;
|
|
final canConfirm = ['CREATED', 'CONFIRM_FAILED'].contains(status);
|
|
final canRequest = ['CONFIRMED_ALL', 'REQUEST_FAILED'].contains(status);
|
|
final canPoll =
|
|
['REQUESTED', 'POLL_FAILED'].contains(status) || _showPollButton;
|
|
final canDownloadReport = status == 'COMPLETED';
|
|
|
|
final List<Widget> buttons = [];
|
|
|
|
if (canConfirm) {
|
|
buttons.add(
|
|
_inlineActionButton(
|
|
icon: Icons.check_circle_outline_rounded,
|
|
label: 'Confirm',
|
|
color: theme.colorScheme.primary,
|
|
isLoading: isActing,
|
|
onPressed: _onConfirm,
|
|
),
|
|
);
|
|
} else if (canRequest) {
|
|
buttons.add(
|
|
_inlineActionButton(
|
|
icon: Icons.send_rounded,
|
|
label: 'Push Payment',
|
|
color: const Color(0xFF10B981),
|
|
isLoading: isActing,
|
|
onPressed: _onRequest,
|
|
),
|
|
);
|
|
} else if (canPoll) {
|
|
buttons.add(
|
|
_inlineActionButton(
|
|
icon: Icons.refresh_rounded,
|
|
label: 'Check Status',
|
|
color: const Color(0xFFF59E0B),
|
|
isLoading: isActing,
|
|
onPressed: _onPoll,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (canDownloadReport) {
|
|
buttons.add(
|
|
_inlineActionButton(
|
|
icon: Icons.download_rounded,
|
|
label: 'Report',
|
|
color: const Color(0xFF6366F1),
|
|
isLoading: isActing,
|
|
onPressed: _onDownloadReport,
|
|
),
|
|
);
|
|
}
|
|
|
|
if (buttons.isEmpty) return const SizedBox.shrink();
|
|
|
|
return Row(
|
|
children: [
|
|
...buttons.map(
|
|
(btn) =>
|
|
Padding(padding: const EdgeInsets.only(right: 8), child: btn),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _inlineActionButton({
|
|
required IconData icon,
|
|
required String label,
|
|
required Color color,
|
|
required bool isLoading,
|
|
required VoidCallback onPressed,
|
|
}) {
|
|
return Material(
|
|
color: Colors.transparent,
|
|
child: InkWell(
|
|
borderRadius: BorderRadius.circular(10),
|
|
onTap: isLoading ? null : onPressed,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.1),
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: color.withValues(alpha: 0.3)),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (isLoading)
|
|
SizedBox(
|
|
width: 14,
|
|
height: 14,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
valueColor: AlwaysStoppedAnimation<Color>(color),
|
|
),
|
|
)
|
|
else
|
|
Icon(icon, size: 16, color: color),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: color,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// Action Bar — search, filter, edit, refresh
|
|
// ──────────────────────────────────────────────────────────────
|
|
Widget _buildActionBar(ThemeData theme, bool isDark) {
|
|
final hasFilters =
|
|
controller.model.batchItemsStatusFilter != null ||
|
|
controller.model.batchItemsRecipientAccount != null;
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(right: 8.0),
|
|
child: TextFormField(
|
|
controller: _searchController,
|
|
decoration: InputDecoration(
|
|
hintText: 'Search by recipient name',
|
|
prefixIcon: const Icon(Icons.search, size: 20),
|
|
suffixIcon: _searchController.text.isNotEmpty
|
|
? IconButton(
|
|
icon: const Icon(Icons.clear, size: 18),
|
|
onPressed: () {
|
|
_searchController.clear();
|
|
final batchId = _batch?.id ?? '';
|
|
if (batchId.isNotEmpty) {
|
|
controller.searchBatchItems(
|
|
batchId,
|
|
status:
|
|
controller.model.batchItemsStatusFilter,
|
|
account: controller
|
|
.model
|
|
.batchItemsRecipientAccount,
|
|
);
|
|
}
|
|
},
|
|
)
|
|
: null,
|
|
filled: true,
|
|
fillColor: Colors.grey.shade100,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
borderSide: BorderSide(color: theme.colorScheme.primary),
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 10,
|
|
),
|
|
),
|
|
style: const TextStyle(fontSize: 14),
|
|
textInputAction: TextInputAction.search,
|
|
onFieldSubmitted: (value) {
|
|
final batchId = _batch?.id ?? '';
|
|
if (batchId.isNotEmpty) {
|
|
controller.searchBatchItems(
|
|
batchId,
|
|
search: value.isNotEmpty ? value : null,
|
|
status: controller.model.batchItemsStatusFilter,
|
|
account: controller.model.batchItemsRecipientAccount,
|
|
);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
if (_editMode)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8.0),
|
|
child: _saveButton(theme),
|
|
),
|
|
if (!_editMode &&
|
|
_editableStatuses.contains(_batch?.status?.toUpperCase() ?? ''))
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8.0),
|
|
child: _actionIconButton(
|
|
icon: Icons.edit_rounded,
|
|
tooltip: 'Edit items',
|
|
isActive: false,
|
|
color: Colors.grey.shade700,
|
|
bgColor: Colors.grey.shade100,
|
|
onPressed: _toggleEditMode,
|
|
),
|
|
),
|
|
if (_editMode)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8.0),
|
|
child: _actionIconButton(
|
|
icon: Icons.edit_off_rounded,
|
|
tooltip: 'Cancel item edits',
|
|
isActive: false,
|
|
color: Colors.grey.shade700,
|
|
bgColor: Colors.grey.shade100,
|
|
onPressed: _toggleEditMode,
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8.0),
|
|
child: _actionIconButton(
|
|
icon: Icons.filter_list,
|
|
tooltip: 'Filter items',
|
|
isActive: hasFilters,
|
|
color: hasFilters
|
|
? theme.colorScheme.primary
|
|
: Colors.grey.shade700,
|
|
bgColor: hasFilters
|
|
? theme.colorScheme.primary
|
|
: Colors.grey.shade100,
|
|
onPressed: _showFilterSheet,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (hasFilters)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 8),
|
|
child: Wrap(
|
|
spacing: 6,
|
|
runSpacing: 6,
|
|
children: [
|
|
if (controller.model.batchItemsStatusFilter != null)
|
|
_activeFilterChip(
|
|
'Status: ${controller.model.batchItemsStatusFilter}',
|
|
() {
|
|
controller.model.batchItemsStatusFilter = null;
|
|
final batchId = _batch?.id ?? '';
|
|
if (batchId.isNotEmpty) {
|
|
controller.searchBatchItems(
|
|
batchId,
|
|
search: _searchController.text.isNotEmpty
|
|
? _searchController.text
|
|
: null,
|
|
account: controller.model.batchItemsRecipientAccount,
|
|
);
|
|
}
|
|
},
|
|
theme,
|
|
),
|
|
if (controller.model.batchItemsRecipientAccount != null)
|
|
_activeFilterChip(
|
|
'Account: ${controller.model.batchItemsRecipientAccount}',
|
|
() {
|
|
controller.model.batchItemsRecipientAccount = null;
|
|
_accountController.clear();
|
|
final batchId = _batch?.id ?? '';
|
|
if (batchId.isNotEmpty) {
|
|
controller.searchBatchItems(
|
|
batchId,
|
|
search: _searchController.text.isNotEmpty
|
|
? _searchController.text
|
|
: null,
|
|
status: controller.model.batchItemsStatusFilter,
|
|
);
|
|
}
|
|
},
|
|
theme,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _saveButton(ThemeData theme) {
|
|
final isSaving = controller.model.isActing;
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: isSaving
|
|
? theme.colorScheme.primary.withValues(alpha: 0.1)
|
|
: Colors.grey.shade100,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: IconButton(
|
|
icon: isSaving
|
|
? SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2.5,
|
|
valueColor: AlwaysStoppedAnimation<Color>(
|
|
theme.colorScheme.primary,
|
|
),
|
|
),
|
|
)
|
|
: Icon(Icons.save_rounded, color: Colors.grey.shade700, size: 20),
|
|
tooltip: isSaving ? 'Saving...' : 'Save',
|
|
onPressed: isSaving
|
|
? null
|
|
: () async {
|
|
final result = await _saveEdits();
|
|
if (!result) {
|
|
// Handle save failure
|
|
}
|
|
},
|
|
constraints: const BoxConstraints(minWidth: 42, minHeight: 42),
|
|
padding: EdgeInsets.zero,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _actionIconButton({
|
|
required IconData icon,
|
|
required String tooltip,
|
|
required bool isActive,
|
|
required Color color,
|
|
required Color bgColor,
|
|
required VoidCallback onPressed,
|
|
}) {
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: bgColor,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: IconButton(
|
|
icon: Icon(icon, color: color, size: 20),
|
|
tooltip: tooltip,
|
|
onPressed: onPressed,
|
|
constraints: const BoxConstraints(minWidth: 42, minHeight: 42),
|
|
padding: EdgeInsets.zero,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _activeFilterChip(
|
|
String label,
|
|
VoidCallback onRemove,
|
|
ThemeData theme,
|
|
) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.primary.withAlpha(20),
|
|
borderRadius: BorderRadius.circular(20),
|
|
border: Border.all(color: theme.colorScheme.primary.withAlpha(60)),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: theme.colorScheme.primary,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
const SizedBox(width: 4),
|
|
InkWell(
|
|
onTap: onRemove,
|
|
borderRadius: BorderRadius.circular(10),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(2),
|
|
child: Icon(
|
|
Icons.close,
|
|
size: 14,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// ──────────────────────────────────────────────────────────────
|
|
// Items List
|
|
// ──────────────────────────────────────────────────────────────
|
|
Widget _buildItemsList(GroupBatch batch, ThemeData theme, bool isDark) {
|
|
if (controller.model.isLoading && controller.model.batchItems.isEmpty) {
|
|
return const Center(
|
|
child: Padding(
|
|
padding: EdgeInsets.all(32),
|
|
child: CircularProgressIndicator(),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (controller.model.batchItems.isEmpty) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 40),
|
|
child: Center(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.inbox_outlined, size: 48, color: Colors.grey.shade400),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'No items found.',
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
color: Colors.grey.shade500,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// Show item count
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (controller.model.batchItemsTotalElements > 0)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Text(
|
|
'${controller.model.batchItems.length} of ${controller.model.batchItemsTotalElements} items',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w500,
|
|
color: Colors.grey.shade500,
|
|
),
|
|
),
|
|
),
|
|
...controller.model.batchItems.map(
|
|
(item) => Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: _buildItemCard(item, batch, theme, isDark),
|
|
),
|
|
),
|
|
if (controller.model.batchItemsIsLoadingMore)
|
|
const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 16),
|
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _buildItemCard(
|
|
GroupBatchItem item,
|
|
GroupBatch batch,
|
|
ThemeData theme,
|
|
bool isDark,
|
|
) {
|
|
if (_editMode) {
|
|
return _buildEditableItemCard(item, batch, theme, isDark);
|
|
}
|
|
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(
|
|
color: isDark
|
|
? Colors.white.withValues(alpha: 0.06)
|
|
: Colors.grey.shade200,
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: isDark
|
|
? Colors.black.withValues(alpha: 0.2)
|
|
: Colors.black.withValues(alpha: 0.03),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
padding: const EdgeInsets.all(10),
|
|
child: Row(
|
|
children: [
|
|
CircleAvatar(
|
|
radius: 18,
|
|
backgroundColor: theme.colorScheme.primary.withAlpha(25),
|
|
child: Text(
|
|
item.recipientName?.isNotEmpty == true
|
|
? item.recipientName![0].toUpperCase()
|
|
: '?',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
item.recipientName ?? 'Unknown',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: isDark ? Colors.white : Colors.black87,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
item.recipientAccount ?? '',
|
|
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
|
),
|
|
if (item.billName != null || item.billProductName != null)
|
|
const SizedBox(height: 4),
|
|
if (item.billName != null || item.billProductName != null)
|
|
_buildBillInfoRow(item, isDark),
|
|
if (item.creditAddress != null) Text(item.creditAddress ?? ''),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text(
|
|
'${batch.currency ?? 'USD'} ${item.amount?.toStringAsFixed(2) ?? '—'}',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
color: isDark ? Colors.white : Colors.black87,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
StatusChip(status: item.status ?? 'PENDING'),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildBillInfoRow(GroupBatchItem item, bool isDark) {
|
|
final parts = <InlineSpan>[];
|
|
if (item.billName != null && item.billName!.isNotEmpty) {
|
|
parts.addAll([
|
|
TextSpan(
|
|
text: item.billName,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w500,
|
|
color: isDark ? Colors.white54 : Colors.grey.shade600,
|
|
),
|
|
),
|
|
]);
|
|
}
|
|
if (item.billProductName != null && item.billProductName!.isNotEmpty) {
|
|
if (parts.isNotEmpty) {
|
|
parts.add(
|
|
TextSpan(
|
|
text: ' • ',
|
|
style: TextStyle(fontSize: 11, color: Colors.grey.shade400),
|
|
),
|
|
);
|
|
}
|
|
parts.add(
|
|
TextSpan(
|
|
text: item.billProductName,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w400,
|
|
color: isDark ? Colors.white38 : Colors.grey.shade500,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return RichText(text: TextSpan(children: parts));
|
|
}
|
|
|
|
Widget _buildProviderBadge(GroupBatchItem item, bool isDark) {
|
|
return Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.store_rounded, size: 11, color: Colors.grey.shade400),
|
|
const SizedBox(width: 3),
|
|
Text(
|
|
item.providerLabel ?? '',
|
|
style: TextStyle(
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.w500,
|
|
color: isDark ? Colors.white38 : Colors.grey.shade500,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
/// Edit-mode card: provider dropdown in the middle + editable amount + product dropdown
|
|
Widget _buildEditableItemCard(
|
|
GroupBatchItem item,
|
|
GroupBatch batch,
|
|
ThemeData theme,
|
|
bool isDark,
|
|
) {
|
|
final providers = controller.model.providers;
|
|
final itemId = item.id ?? '';
|
|
final selectedProvider = _editedProviders[itemId];
|
|
final amountController = _editedAmounts[itemId];
|
|
|
|
// Get cached products for the selected provider
|
|
final providerProducts = selectedProvider != null
|
|
? (controller.model.providerProducts[selectedProvider.id] ??
|
|
<BillProduct>[])
|
|
: <BillProduct>[];
|
|
final selectedProduct = _editedProducts[itemId];
|
|
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
|
|
borderRadius: BorderRadius.circular(14),
|
|
border: Border.all(
|
|
color: _editMode
|
|
? theme.colorScheme.primary.withValues(alpha: 0.3)
|
|
: (isDark
|
|
? Colors.white.withValues(alpha: 0.06)
|
|
: Colors.grey.shade200),
|
|
width: _editMode ? 1.5 : 1,
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: isDark
|
|
? Colors.black.withValues(alpha: 0.2)
|
|
: Colors.black.withValues(alpha: 0.03),
|
|
blurRadius: 8,
|
|
offset: const Offset(0, 2),
|
|
),
|
|
],
|
|
),
|
|
padding: const EdgeInsets.all(10),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Top row: Avatar + Name + Amount input
|
|
Row(
|
|
children: [
|
|
CircleAvatar(
|
|
radius: 16,
|
|
backgroundColor: theme.colorScheme.primary.withAlpha(25),
|
|
child: Text(
|
|
item.recipientName?.isNotEmpty == true
|
|
? item.recipientName![0].toUpperCase()
|
|
: '?',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: theme.colorScheme.primary,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
item.recipientName ?? 'Unknown',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: isDark ? Colors.white : Colors.black87,
|
|
),
|
|
),
|
|
Text(
|
|
item.recipientPhone ?? '',
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
color: Colors.grey.shade500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
// Editable amount field (readOnly when products exist — amount comes from product)
|
|
SizedBox(
|
|
width: 110,
|
|
height: 38,
|
|
child: TextFormField(
|
|
controller: amountController,
|
|
readOnly: providerProducts.isNotEmpty,
|
|
keyboardType: const TextInputType.numberWithOptions(
|
|
decimal: true,
|
|
),
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w700,
|
|
color: isDark ? Colors.white : Colors.black87,
|
|
),
|
|
decoration: InputDecoration(
|
|
prefixText: '${batch.currency ?? 'USD'} ',
|
|
prefixStyle: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w500,
|
|
color: Colors.grey.shade500,
|
|
),
|
|
filled: true,
|
|
fillColor: isDark
|
|
? Colors.white.withValues(alpha: 0.06)
|
|
: Colors.grey.shade50,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: BorderSide(color: theme.colorScheme.primary),
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 8,
|
|
),
|
|
isDense: true,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
// Provider + Product dropdown row (2 columns side by side)
|
|
if (providers.isNotEmpty)
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: SizedBox(
|
|
height: 40,
|
|
child: DropdownButtonFormField<BillProvider>(
|
|
value: selectedProvider ?? providers.first,
|
|
isExpanded: true,
|
|
decoration: InputDecoration(
|
|
labelText: 'Provider',
|
|
labelStyle: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey.shade500,
|
|
),
|
|
filled: true,
|
|
fillColor: isDark
|
|
? Colors.white.withValues(alpha: 0.06)
|
|
: Colors.grey.shade50,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 6,
|
|
),
|
|
isDense: true,
|
|
),
|
|
items: providers.map((provider) {
|
|
return DropdownMenuItem<BillProvider>(
|
|
value: provider,
|
|
child: Text(
|
|
provider.label,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: isDark ? Colors.white : Colors.black87,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
);
|
|
}).toList(),
|
|
onChanged: (provider) {
|
|
if (provider != null && itemId.isNotEmpty) {
|
|
setState(() {
|
|
_editedProviders[itemId] = provider;
|
|
// Clear the product when provider changes
|
|
_editedProducts.remove(itemId);
|
|
});
|
|
// Load products for the new provider
|
|
_onProviderChangedInEdit(itemId, provider);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: _buildEditProductDropdown(
|
|
itemId: itemId,
|
|
provider: selectedProvider ?? providers.first,
|
|
isDark: isDark,
|
|
theme: theme,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
// Status chip on the bottom-right
|
|
if (item.status != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 6),
|
|
child: Align(
|
|
alignment: Alignment.centerRight,
|
|
child: StatusChip(status: item.status!),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Builds the product dropdown for an editable item card.
|
|
/// Shows a loading indicator if products are being fetched.
|
|
Widget _buildEditProductDropdown({
|
|
required String itemId,
|
|
required BillProvider provider,
|
|
required bool isDark,
|
|
required ThemeData theme,
|
|
}) {
|
|
final isLoading = controller.model.loadingProviderProducts.contains(
|
|
provider.id,
|
|
);
|
|
final products =
|
|
(controller.model.providerProducts[provider.id] ?? <BillProduct>[]);
|
|
final selectedProduct = _editedProducts[itemId];
|
|
|
|
if (isLoading) {
|
|
return SizedBox(
|
|
height: 40,
|
|
child: Row(
|
|
children: [
|
|
const SizedBox(
|
|
width: 20,
|
|
height: 20,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'Loading products...',
|
|
style: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
if (products.isEmpty) {
|
|
return SizedBox(
|
|
height: 40,
|
|
child: InputDecorator(
|
|
decoration: InputDecoration(
|
|
labelText: 'Product',
|
|
labelStyle: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
|
filled: true,
|
|
fillColor: isDark
|
|
? Colors.white.withValues(alpha: 0.06)
|
|
: Colors.grey.shade50,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 6,
|
|
),
|
|
isDense: true,
|
|
),
|
|
child: Text(
|
|
'No products',
|
|
style: TextStyle(fontSize: 13, color: Colors.grey.shade500),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
return SizedBox(
|
|
height: 40,
|
|
child: DropdownButtonFormField<BillProduct>(
|
|
value: selectedProduct ?? products.first,
|
|
isExpanded: true,
|
|
decoration: InputDecoration(
|
|
labelText: 'Product',
|
|
labelStyle: TextStyle(fontSize: 12, color: Colors.grey.shade500),
|
|
filled: true,
|
|
fillColor: isDark
|
|
? Colors.white.withValues(alpha: 0.06)
|
|
: Colors.grey.shade50,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(8),
|
|
borderSide: BorderSide.none,
|
|
),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 10,
|
|
vertical: 6,
|
|
),
|
|
isDense: true,
|
|
),
|
|
items: products.map((product) {
|
|
return DropdownMenuItem<BillProduct>(
|
|
value: product,
|
|
child: Text(
|
|
product.displayName.isNotEmpty
|
|
? product.displayName
|
|
: product.name,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: isDark ? Colors.white : Colors.black87,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
);
|
|
}).toList(),
|
|
onChanged: (product) {
|
|
if (product != null && itemId.isNotEmpty) {
|
|
setState(() {
|
|
_editedProducts[itemId] = product;
|
|
// Update amount field with product's default amount
|
|
if (product.defaultAmount > 0) {
|
|
final ctrl = _editedAmounts[itemId];
|
|
if (ctrl != null) {
|
|
ctrl.text = product.defaultAmount.toStringAsFixed(2);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Called when the provider changes for an item in edit mode.
|
|
/// Triggers product loading for the new provider and auto-updates amount.
|
|
void _onProviderChangedInEdit(String itemId, BillProvider provider) {
|
|
// If products are already cached, update the amount now
|
|
final cached = controller.model.providerProducts[provider.id];
|
|
if (cached != null && cached.isNotEmpty) {
|
|
final ctrl = _editedAmounts[itemId];
|
|
if (ctrl != null && cached.first.defaultAmount > 0) {
|
|
ctrl.text = cached.first.defaultAmount.toStringAsFixed(2);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Load products and update amount when they arrive
|
|
controller.loadProviderProducts(provider.id).then((result) {
|
|
if (!mounted) return;
|
|
final products = result.data;
|
|
if (products != null && products.isNotEmpty) {
|
|
final ctrl = _editedAmounts[itemId];
|
|
if (ctrl != null && products.first.defaultAmount > 0) {
|
|
ctrl.text = products.first.defaultAmount.toStringAsFixed(2);
|
|
}
|
|
setState(() {});
|
|
}
|
|
});
|
|
}
|
|
}
|