completed group batch feature

This commit is contained in:
2026-06-08 09:17:36 +02:00
parent 2dd790fe6e
commit fc616d6316
46 changed files with 4610 additions and 2669 deletions

View File

@@ -5,6 +5,7 @@ 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/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';
@@ -147,11 +148,10 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
final batch = _batch;
if (batch == null) return;
final processor = _selectedProcessor;
if (processor == null) {
_showError('Please select a payment processor first.');
return;
}
// Show the bottom sheet so the user can pick a processor and confirm.
final processor = await _showProcessorSelectorBottomSheet();
if (processor == null) return; // user dismissed
_selectedProcessor = processor;
// Persist the selected processor on the batch before confirming
final updateReq = GroupBatchUpdateRequest(
@@ -162,10 +162,9 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
paymentProcessorImage: processor.image,
);
final updateResult = await controller.updateBatch(updateReq);
if (!mounted) return;
if (!updateResult.isSuccess) {
if (mounted) {
_showError(updateResult.error ?? 'Failed to update processor.');
}
_showError(updateResult.error ?? 'Failed to update processor.');
return;
}
@@ -177,15 +176,281 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
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: 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,
),
],
),
),
),
);
}).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!, _userId);
if (!mounted) return;
await _init(); // refresh batch and items after request
if (!result.isSuccess) {
_showError(result.error ?? 'Request failed.');
return;
@@ -195,12 +460,10 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
final pollingStatus = txn.pollingStatus?.toUpperCase();
if (pollingStatus != null && _terminalStatuses.contains(pollingStatus)) {
await controller.listBatchItems(batch.id!);
await _init(); // refresh batch and items after request
if (_successStatuses.contains(pollingStatus)) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Payment completed successfully.')),
);
AppSnackBar.showSuccess(context, 'Payment completed successfully.');
}
}
} else {
@@ -208,11 +471,12 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
if (!mounted) return;
if (pollResult.isSuccess) {
setState(() => _showPollButton = false);
await controller.listBatchItems(batch.id!);
} else {
setState(() => _showPollButton = true);
_showError('Auto-poll failed. You can retry manually.');
}
await _init(); // refresh batch and items after request
}
}
@@ -223,7 +487,7 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
if (!mounted) return;
if (result.isSuccess) {
setState(() => _showPollButton = false);
await controller.listBatchItems(batch.id!);
await _init(); // refresh batch and items after poll
} else {
_showError(result.error ?? 'Poll failed.');
}
@@ -251,10 +515,16 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
}
}
Future<void> _onRefresh() async {
setState(() {
_initialLoading = true;
_loadError = null;
});
await _init();
}
void _showError(String message) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
AppSnackBar.showError(context, message);
}
// ──────────────────────────────────────────────────────────────
@@ -376,17 +646,6 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
child: _buildBatchHeader(batch, theme, isDark),
),
),
if (batch.status?.toUpperCase() == 'CREATED' &&
controller.model.processors.isNotEmpty) ...[
const SizedBox(height: 16),
FadeTransition(
opacity: _cardFadeAnimation,
child: SlideTransition(
position: _cardSlideAnimation,
child: _buildProcessorSelector(batch, theme),
),
),
],
const SizedBox(height: 16),
FadeTransition(
opacity: _cardFadeAnimation,
@@ -478,6 +737,43 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
);
}
// ──────────────────────────────────────────────────────────────
// Refresh Button (sits next to status pill; reloads the batch)
// ──────────────────────────────────────────────────────────────
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: 32,
height: 32,
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: 16,
color: isDark ? Colors.white70 : Colors.grey.shade700,
),
),
),
),
);
}
// ──────────────────────────────────────────────────────────────
// Dotted Divider
// ──────────────────────────────────────────────────────────────
@@ -505,7 +801,6 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
final currency = batch.currency ?? 'USD';
final value = batch.value;
final status = batch.status ?? 'UNKNOWN';
final processorName = batch.paymentProcessorName ?? '';
final createdAt = batch.createdAt;
final parsedDate = createdAt != null ? DateTime.tryParse(createdAt) : null;
@@ -538,7 +833,14 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20),
child: Column(
children: [
_buildStatusBadge(status),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildStatusBadge(status),
const SizedBox(width: 8),
_buildRefreshButton(theme, isDark),
],
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
@@ -564,24 +866,6 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
),
],
),
if (processorName.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: primaryColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(20),
),
child: Text(
processorName,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: primaryColor,
),
),
),
],
const SizedBox(height: 4),
if (parsedDate != null)
Text(
@@ -600,100 +884,6 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
);
}
// ──────────────────────────────────────────────────────────────
// Processor Selection Row
// ──────────────────────────────────────────────────────────────
Widget _buildProcessorSelector(GroupBatch batch, ThemeData theme) {
final processors = controller.model.processors;
if (processors.isEmpty) return const SizedBox.shrink();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Select payment processor to proceed',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Colors.grey.shade500,
),
),
const SizedBox(height: 8),
SizedBox(
height: 80,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: processors.length,
separatorBuilder: (_, __) => const SizedBox(width: 10),
itemBuilder: (context, index) {
final processor = processors[index];
final isSelected = _selectedProcessor == processor;
return GestureDetector(
onTap: () {
setState(() => _selectedProcessor = processor);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
width: 140,
decoration: BoxDecoration(
color: isSelected
? theme.colorScheme.primary.withValues(alpha: 0.12)
: (theme.brightness == Brightness.dark
? Colors.white.withValues(alpha: 0.06)
: Colors.white),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isSelected
? theme.colorScheme.primary
: (theme.brightness == Brightness.dark
? Colors.white.withValues(alpha: 0.08)
: Colors.grey.shade200),
width: isSelected ? 2 : 1,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.asset(
'assets/${processor.image}',
width: 28,
height: 28,
errorBuilder: (_, __, ___) => Icon(
Icons.payment_rounded,
size: 28,
color: theme.colorScheme.primary,
),
),
),
const SizedBox(height: 6),
Text(
processor.name,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isSelected
? theme.colorScheme.primary
: (theme.brightness == Brightness.dark
? Colors.white70
: Colors.black87),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
),
);
},
),
),
],
);
}
// ──────────────────────────────────────────────────────────────
// Header Action Buttons
// ──────────────────────────────────────────────────────────────
@@ -707,19 +897,17 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
buttons.add(
_modernActionButton(
icon: Icons.check_circle_outline_rounded,
label: 'Confirm',
color: _selectedProcessor != null
? theme.colorScheme.primary
: Colors.grey,
label: 'Confirm Items',
color: theme.colorScheme.primary,
isLoading: isActing,
onPressed: _onConfirm,
),
);
} else if (status == 'CONFIRMED_ALL') {
} else if (status == 'CONFIRMED_ALL' || status == 'REQUEST_FAILED') {
buttons.add(
_modernActionButton(
icon: Icons.send_rounded,
label: 'Request',
label: 'Push Payment Request',
color: const Color(0xFF10B981),
isLoading: isActing,
onPressed: _onRequest,