495 lines
18 KiB
Dart
495 lines
18 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:qpay/screens/groups/models/group_batch_model.dart';
|
|
import 'package:qpay/screens/groups/models/batch_item_update_model.dart';
|
|
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
|
|
import 'package:qpay/models/responsive_policy.dart';
|
|
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
|
|
import 'package:qpay/screens/provider/provider_controller.dart';
|
|
import 'package:qpay/widgets/app_snack_bar.dart';
|
|
import 'package:shared_preferences/shared_preferences.dart';
|
|
|
|
class BatchCreateScreen extends StatefulWidget {
|
|
final RecipientGroup group;
|
|
|
|
const BatchCreateScreen({super.key, required this.group});
|
|
|
|
@override
|
|
State<BatchCreateScreen> createState() => _BatchCreateScreenState();
|
|
}
|
|
|
|
class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
|
late BatchController batchController;
|
|
late ProviderController providerController;
|
|
final _formKey = GlobalKey<FormState>();
|
|
final _descriptionController = TextEditingController();
|
|
final _amountController = TextEditingController();
|
|
final _debitPhoneController = TextEditingController();
|
|
String _currency = 'USD';
|
|
bool _loadingDropdowns = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
batchController = BatchController(context);
|
|
providerController = ProviderController(context);
|
|
// Sync listeners so the UI rebuilds when either controller changes
|
|
batchController.addListener(_onControllerChange);
|
|
providerController.addListener(_onControllerChange);
|
|
_init();
|
|
}
|
|
|
|
void _onControllerChange() {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
// When products are available and a selected product exists with a non-zero
|
|
// defaultAmount, populate the amount field (only if it's empty or was cleared).
|
|
if (providerController.model.products.isNotEmpty &&
|
|
providerController.model.selectedProduct != null &&
|
|
_amountController.text.isEmpty) {
|
|
final product = providerController.model.selectedProduct!;
|
|
if (product.defaultAmount > 0) {
|
|
_amountController.text = product.defaultAmount.toStringAsFixed(2);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
Future<void> _init() async {
|
|
final prefs = await SharedPreferences.getInstance();
|
|
if (prefs.containsKey('phone')) {
|
|
_debitPhoneController.text = prefs.getString('phone')!;
|
|
}
|
|
await Future.wait([
|
|
providerController.loadProviders(_currency),
|
|
batchController.loadProcessors(_currency),
|
|
]);
|
|
if (mounted) setState(() => _loadingDropdowns = false);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
batchController.removeListener(_onControllerChange);
|
|
providerController.removeListener(_onControllerChange);
|
|
batchController.dispose();
|
|
providerController.dispose();
|
|
_descriptionController.dispose();
|
|
_amountController.dispose();
|
|
_debitPhoneController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _submit() async {
|
|
if (!_formKey.currentState!.validate()) return;
|
|
|
|
final amount = double.tryParse(_amountController.text.trim());
|
|
if (amount == null || amount <= 0) {
|
|
AppSnackBar.show(context, 'Please enter a valid amount.');
|
|
return;
|
|
}
|
|
|
|
final prefs = await SharedPreferences.getInstance();
|
|
final userId = prefs.getString('userId') ?? '';
|
|
final workspaceId = prefs.getString('workspaceId') ?? '';
|
|
|
|
final req = CreateBatchRequest(
|
|
recipientGroupId: widget.group.id ?? '',
|
|
userId: userId,
|
|
workspaceId: workspaceId,
|
|
currency: _currency,
|
|
description: _descriptionController.text.trim(),
|
|
amount: amount,
|
|
);
|
|
|
|
// Step 1: Create the batch
|
|
final result = await batchController.createBatch(req);
|
|
if (!mounted) return;
|
|
|
|
if (result.isSuccess) {
|
|
final batch = result.data!;
|
|
|
|
// Step 2: Update batch items with selected provider & product
|
|
final selectedProvider = providerController.model.selectedProvider;
|
|
final selectedProduct = providerController.model.selectedProduct;
|
|
|
|
if (selectedProvider != null && batch.id != null) {
|
|
await _updateBatchItems(
|
|
batch.id!,
|
|
selectedProvider,
|
|
selectedProduct,
|
|
workspaceId,
|
|
);
|
|
}
|
|
|
|
if (!mounted) return;
|
|
context.pushReplacement('/groups/batches/${batch.id}');
|
|
} else {
|
|
AppSnackBar.showError(context, result.error ?? 'Failed to create batch.');
|
|
}
|
|
}
|
|
|
|
Future<void> _updateBatchItems(
|
|
String batchId,
|
|
BillProvider provider,
|
|
BillProduct? product,
|
|
String workspaceId,
|
|
) async {
|
|
// Fetch the batch items so we can update them
|
|
final itemsResult = await batchController.listBatchItems(batchId);
|
|
if (!mounted || !itemsResult.isSuccess) return;
|
|
|
|
if (itemsResult.data!.isEmpty) return;
|
|
|
|
final updateItems = itemsResult.data!.map((item) {
|
|
return GroupBatchItemUpdateRequest(
|
|
id: item.id,
|
|
amount: item.amount,
|
|
recipientName: item.recipientName,
|
|
recipientPhone: item.recipientPhone,
|
|
providerImage: provider.image,
|
|
providerLabel: provider.label,
|
|
billClientId: provider.clientId,
|
|
billName: provider.name,
|
|
billProductName: product?.name ?? '',
|
|
);
|
|
}).toList();
|
|
|
|
final updateList = GroupBatchItemUpdateList(
|
|
items: updateItems,
|
|
workspaceId: workspaceId,
|
|
);
|
|
|
|
await batchController.updateBatchItems(batchId, updateList);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(title: const Text('New Batch'), centerTitle: true),
|
|
body: ListenableBuilder(
|
|
listenable: Listenable.merge([batchController, providerController]),
|
|
builder: (context, _) {
|
|
if (_loadingDropdowns) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
return Align(
|
|
alignment: Alignment.topCenter,
|
|
child: LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final width = constraints.maxWidth > ResponsivePolicy.md
|
|
? ResponsivePolicy.md.toDouble()
|
|
: constraints.maxWidth;
|
|
return SizedBox(
|
|
width: width,
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Form(
|
|
key: _formKey,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
// Row 1: Description + Currency
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 2,
|
|
child: TextFormField(
|
|
controller: _descriptionController,
|
|
textInputAction: TextInputAction.next,
|
|
decoration: InputDecoration(
|
|
labelText: 'Description (optional)',
|
|
hintText:
|
|
'e.g. Monthly salaries for January',
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: DropdownButtonFormField<String>(
|
|
initialValue: _currency,
|
|
decoration: InputDecoration(
|
|
labelText: 'Currency',
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
),
|
|
items: const [
|
|
DropdownMenuItem(
|
|
value: 'USD',
|
|
child: Text('USD'),
|
|
),
|
|
DropdownMenuItem(
|
|
value: 'ZWG',
|
|
child: Text('ZWG'),
|
|
),
|
|
],
|
|
onChanged: (v) {
|
|
if (v != null) _updateCurrency(v);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
// Row 2: Provider + Product
|
|
Row(
|
|
children: [
|
|
Expanded(child: _buildProviderDropdown()),
|
|
const SizedBox(width: 12),
|
|
Expanded(child: _buildProductDropdown()),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
// Row 3: Amount + Phone
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
flex: 1,
|
|
child: TextFormField(
|
|
controller: _amountController,
|
|
readOnly: providerController
|
|
.model
|
|
.products
|
|
.isNotEmpty,
|
|
keyboardType:
|
|
const TextInputType.numberWithOptions(
|
|
decimal: true,
|
|
),
|
|
textInputAction: TextInputAction.next,
|
|
decoration: InputDecoration(
|
|
labelText:
|
|
'Amount each recipient will receive',
|
|
hintText:
|
|
providerController
|
|
.model
|
|
.products
|
|
.isNotEmpty
|
|
? 'Set by product'
|
|
: 'Amount',
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
),
|
|
validator: (v) =>
|
|
(v == null || v.trim().isEmpty)
|
|
? 'Required'
|
|
: null,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
flex: 1,
|
|
child: TextFormField(
|
|
controller: _debitPhoneController,
|
|
keyboardType: TextInputType.phone,
|
|
textInputAction: TextInputAction.done,
|
|
decoration: InputDecoration(
|
|
labelText: 'Your Phone Number',
|
|
hintText: '263773000000',
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
),
|
|
validator: (v) =>
|
|
(v == null || v.trim().isEmpty)
|
|
? 'Phone number is required'
|
|
: null,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 32),
|
|
ElevatedButton(
|
|
onPressed: batchController.model.isActing
|
|
? null
|
|
: _submit,
|
|
style: ElevatedButton.styleFrom(
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
),
|
|
child: batchController.model.isActing
|
|
? const SizedBox(
|
|
height: 20,
|
|
width: 20,
|
|
child: CircularProgressIndicator(
|
|
strokeWidth: 2,
|
|
color: Colors.white,
|
|
),
|
|
)
|
|
: const Text(
|
|
'Create Batch',
|
|
style: TextStyle(fontSize: 16),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildProviderDropdown() {
|
|
final providers = providerController.model.providers;
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
|
|
if (providerController.model.isLoadingProviders) {
|
|
return const SizedBox(
|
|
height: 56,
|
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
|
);
|
|
}
|
|
|
|
if (providers.isEmpty) {
|
|
return const SizedBox(
|
|
height: 56,
|
|
child: Center(child: Text('No providers available')),
|
|
);
|
|
}
|
|
|
|
return DropdownButtonFormField<BillProvider>(
|
|
initialValue: providerController.model.selectedProvider,
|
|
isExpanded: true,
|
|
decoration: InputDecoration(
|
|
labelText: 'Provider',
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 14,
|
|
),
|
|
),
|
|
items: providers.map((provider) {
|
|
return DropdownMenuItem<BillProvider>(
|
|
value: provider,
|
|
child: Row(
|
|
children: [
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: Image.asset(
|
|
'assets/${provider.image}',
|
|
width: 24,
|
|
height: 24,
|
|
errorBuilder: (_, __, ___) =>
|
|
const Icon(Icons.payment_rounded, size: 20),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Flexible(
|
|
child: Text(
|
|
provider.name,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: isDark ? Colors.white : Colors.black87,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}).toList(),
|
|
onChanged: (provider) {
|
|
if (provider != null) {
|
|
_amountController.clear();
|
|
providerController.selectProvider(provider);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildProductDropdown() {
|
|
final products = providerController.model.products;
|
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
|
|
if (providerController.model.selectedProvider == null) {
|
|
return InputDecorator(
|
|
decoration: InputDecoration(
|
|
labelText: 'Product',
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 14,
|
|
),
|
|
),
|
|
child: Text(
|
|
'Select a provider first',
|
|
style: TextStyle(fontSize: 13, color: Colors.grey.shade500),
|
|
),
|
|
);
|
|
}
|
|
|
|
if (providerController.model.isLoadingProducts) {
|
|
return const SizedBox(
|
|
height: 56,
|
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
|
);
|
|
}
|
|
|
|
if (products.isEmpty) {
|
|
return InputDecorator(
|
|
decoration: InputDecoration(
|
|
labelText: 'Product',
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 14,
|
|
),
|
|
),
|
|
child: Text(
|
|
'No products',
|
|
style: TextStyle(fontSize: 13, color: Colors.grey.shade500),
|
|
),
|
|
);
|
|
}
|
|
|
|
return DropdownButtonFormField<BillProduct>(
|
|
value: providerController.model.selectedProduct,
|
|
isExpanded: true,
|
|
decoration: InputDecoration(
|
|
labelText: 'Product',
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 12,
|
|
vertical: 14,
|
|
),
|
|
),
|
|
items: products.map((product) {
|
|
return DropdownMenuItem<BillProduct>(
|
|
value: product,
|
|
child: Text(
|
|
'${product.displayName.isNotEmpty ? product.displayName : product.name} - \$${product.defaultAmount}',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: isDark ? Colors.white : Colors.black87,
|
|
),
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
);
|
|
}).toList(),
|
|
onChanged: (product) {
|
|
if (product != null) {
|
|
providerController.selectProduct(product);
|
|
if (product.defaultAmount > 0) {
|
|
_amountController.text = product.defaultAmount.toStringAsFixed(2);
|
|
}
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
void _updateCurrency(String v) {
|
|
setState(() => _currency = v);
|
|
providerController.loadProviders(v);
|
|
}
|
|
}
|