completed group batch feature
This commit is contained in:
@@ -6,6 +6,7 @@ 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:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class BatchCreateScreen extends StatefulWidget {
|
||||
@@ -57,17 +58,13 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
||||
final selectedProvider = controller.model.selectedProvider;
|
||||
|
||||
if (selectedProvider == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please select a bill provider.')),
|
||||
);
|
||||
AppSnackBar.show(context, 'Please select a bill provider.');
|
||||
return;
|
||||
}
|
||||
|
||||
final amount = double.tryParse(_amountController.text.trim());
|
||||
if (amount == null || amount <= 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please enter a valid amount.')),
|
||||
);
|
||||
AppSnackBar.show(context, 'Please enter a valid amount.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -106,8 +103,9 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
||||
final batch = result.data!;
|
||||
context.pushReplacement('/groups/${widget.group.id}/batches/${batch.id}');
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(result.error ?? 'Failed to create batch.')),
|
||||
AppSnackBar.showError(
|
||||
context,
|
||||
result.error ?? 'Failed to create batch.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -441,38 +439,4 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProcessorDropdown() {
|
||||
if (controller.model.processors.isEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.black26),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'No processors available',
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
);
|
||||
}
|
||||
return DropdownButtonFormField<PaymentProcessor>(
|
||||
value: controller.model.selectedProcessor,
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
items: controller.model.processors
|
||||
.map(
|
||||
(p) => DropdownMenuItem(
|
||||
value: p,
|
||||
child: Text(p.name, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (p) {
|
||||
if (p != null) controller.selectProcessor(p);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/groups/controllers/group_controller.dart';
|
||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../recipient/recipients_controller.dart';
|
||||
@@ -68,12 +69,9 @@ class _GroupCreateScreenState extends State<GroupCreateScreen> {
|
||||
|
||||
final recipients = _parseRecipients(_recipientsController.text);
|
||||
if (recipients.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
'Please add at least one recipient in name,account format.',
|
||||
),
|
||||
),
|
||||
AppSnackBar.show(
|
||||
context,
|
||||
'Please add at least one recipient in name,account format.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -94,13 +92,12 @@ class _GroupCreateScreenState extends State<GroupCreateScreen> {
|
||||
if (!mounted) return;
|
||||
|
||||
if (result.isSuccess) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Group created successfully.')),
|
||||
);
|
||||
AppSnackBar.showSuccess(context, 'Group created successfully.');
|
||||
context.pop(true); // Return true to indicate a new group was created
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(result.error ?? 'Failed to create group.')),
|
||||
AppSnackBar.showError(
|
||||
context,
|
||||
result.error ?? 'Failed to create group.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ 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/groups/controllers/group_controller.dart';
|
||||
import 'package:qpay/screens/recipient/recipients_controller.dart';
|
||||
import 'package:qpay/widgets/status_chip_widget.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
|
||||
class GroupDetailScreen extends StatefulWidget {
|
||||
final RecipientGroup group;
|
||||
@@ -16,11 +18,15 @@ class GroupDetailScreen extends StatefulWidget {
|
||||
State<GroupDetailScreen> createState() => _GroupDetailScreenState();
|
||||
}
|
||||
|
||||
class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
||||
class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||||
with TickerProviderStateMixin {
|
||||
late GroupController groupController;
|
||||
late BatchController batchController;
|
||||
final _searchController = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
final _memberSearchController = TextEditingController();
|
||||
late final AnimationController _membersAnimController;
|
||||
String _memberQuery = '';
|
||||
String? _statusFilter;
|
||||
String? _currencyFilter;
|
||||
|
||||
@@ -50,6 +56,10 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
||||
_loadData();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_searchController.addListener(() => setState(() {}));
|
||||
_membersAnimController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 600),
|
||||
)..forward();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
@@ -77,140 +87,631 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
||||
}
|
||||
|
||||
void _showMembersSheet() {
|
||||
_memberSearchController.clear();
|
||||
_memberQuery = '';
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (ctx) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.3,
|
||||
maxChildSize: 0.85,
|
||||
expand: false,
|
||||
builder: (context, scrollController) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
builder: (ctx) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.7,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.92,
|
||||
expand: false,
|
||||
builder: (context, scrollController) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setSheetState) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildMembersSheetHandle(),
|
||||
_buildMembersSheetHeader(
|
||||
setSheetState,
|
||||
scrollController,
|
||||
),
|
||||
Expanded(
|
||||
child: ListenableBuilder(
|
||||
listenable: groupController,
|
||||
builder: (context, _) {
|
||||
final allMembers = groupController.model.members;
|
||||
final filtered = _filteredMembers(allMembers);
|
||||
if (groupController.model.isLoading) {
|
||||
return _buildMembersSkeletonList(
|
||||
scrollController,
|
||||
);
|
||||
}
|
||||
if (allMembers.isEmpty) {
|
||||
return _buildMembersEmptyState();
|
||||
}
|
||||
if (filtered.isEmpty) {
|
||||
return _buildNoSearchResultsState();
|
||||
}
|
||||
return ListView.separated(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(height: 10),
|
||||
itemBuilder: (context, index) {
|
||||
return _buildAnimatedMemberCard(
|
||||
index,
|
||||
filtered[index],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMembersSheetHandle() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 10, bottom: 6),
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMembersSheetHeader(
|
||||
StateSetter setSheetState,
|
||||
ScrollController scrollController,
|
||||
) {
|
||||
final total = groupController.model.members.length;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 12, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.people_alt_outlined,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'Members',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'$total ${total == 1 ? "member" : "members"} in this group',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${groupController.model.members.length} member(s)',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.close, color: Colors.grey.shade700),
|
||||
tooltip: 'Close',
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (total > 0) ...[
|
||||
const SizedBox(height: 14),
|
||||
TextFormField(
|
||||
controller: _memberSearchController,
|
||||
onChanged: (v) {
|
||||
setSheetState(() => _memberQuery = v);
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search members...',
|
||||
hintStyle: TextStyle(
|
||||
color: Colors.grey.shade500,
|
||||
fontSize: 14,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child: ListenableBuilder(
|
||||
listenable: groupController,
|
||||
builder: (context, _) {
|
||||
if (groupController.model.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (groupController.model.members.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person_outline,
|
||||
size: 48,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'No members found.',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.separated(
|
||||
controller: scrollController,
|
||||
itemCount: groupController.model.members.length,
|
||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final member = groupController.model.members[index];
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: 2,
|
||||
horizontal: 4,
|
||||
),
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withAlpha(30),
|
||||
child: Text(
|
||||
member.recipient.name?.isNotEmpty == true
|
||||
? member.recipient.name![0].toUpperCase()
|
||||
: '?',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
member.recipient.name ?? '',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
subtitle: Text(member.recipient.account ?? ''),
|
||||
trailing:
|
||||
member.recipient.latestProviderLabel != null &&
|
||||
member
|
||||
.recipient
|
||||
.latestProviderLabel!
|
||||
.isNotEmpty
|
||||
? Chip(
|
||||
label: Text(
|
||||
member.recipient.latestProviderLabel!,
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
prefixIcon: Icon(
|
||||
Icons.search,
|
||||
color: Colors.grey.shade600,
|
||||
size: 20,
|
||||
),
|
||||
suffixIcon: _memberQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: Icon(
|
||||
Icons.clear,
|
||||
color: Colors.grey.shade600,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: () {
|
||||
_memberSearchController.clear();
|
||||
setSheetState(() => _memberQuery = '');
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 12,
|
||||
),
|
||||
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.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<RecipientGroupMember> _filteredMembers(
|
||||
List<RecipientGroupMember> members,
|
||||
) {
|
||||
if (_memberQuery.trim().isEmpty) return members;
|
||||
final q = _memberQuery.toLowerCase().trim();
|
||||
return members.where((m) {
|
||||
final r = m.recipient;
|
||||
return (r.name?.toLowerCase().contains(q) ?? false) ||
|
||||
(r.account?.toLowerCase().contains(q) ?? false) ||
|
||||
(r.email?.toLowerCase().contains(q) ?? false) ||
|
||||
(r.phoneNumber?.toLowerCase().contains(q) ?? false) ||
|
||||
(r.latestProviderLabel?.toLowerCase().contains(q) ?? false);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Widget _buildAnimatedMemberCard(int index, RecipientGroupMember member) {
|
||||
final clampedIndex = index.clamp(0, 5);
|
||||
final animation = Tween<Offset>(
|
||||
begin: const Offset(0, 0.06),
|
||||
end: Offset.zero,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: _membersAnimController,
|
||||
curve: Interval(
|
||||
0.05 * clampedIndex,
|
||||
1.0,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
),
|
||||
);
|
||||
return FadeTransition(
|
||||
opacity: _membersAnimController,
|
||||
child: SlideTransition(
|
||||
position: animation,
|
||||
child: _buildMemberCard(member),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMemberCard(RecipientGroupMember member) {
|
||||
final recipient = member.recipient;
|
||||
final name = recipient.name?.isNotEmpty == true
|
||||
? recipient.name!
|
||||
: 'Unnamed recipient';
|
||||
final initials = _initialsFor(name);
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.grey.shade200, width: 1),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 14,
|
||||
horizontal: 14,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildMemberAvatar(initials),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_buildMemberSubtitleRow(recipient, member.account),
|
||||
if (recipient.phoneNumber != null &&
|
||||
recipient.phoneNumber!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
_buildContactLine(
|
||||
Icons.phone_outlined,
|
||||
recipient.phoneNumber!,
|
||||
),
|
||||
],
|
||||
if (recipient.email != null &&
|
||||
recipient.email!.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
_buildContactLine(
|
||||
Icons.email_outlined,
|
||||
recipient.email!,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildMemberPopupMenu(member),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMemberAvatar(String initials) {
|
||||
return Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Theme.of(context).colorScheme.primary,
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.7),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.25),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
initials,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMemberSubtitleRow(
|
||||
Recipient recipient,
|
||||
String? memberAccount,
|
||||
) {
|
||||
final account = memberAccount?.isNotEmpty == true
|
||||
? memberAccount
|
||||
: recipient.account;
|
||||
final hasAccount = account != null && account.isNotEmpty;
|
||||
final hasProvider = recipient.latestProviderLabel != null &&
|
||||
recipient.latestProviderLabel!.isNotEmpty;
|
||||
if (!hasAccount && !hasProvider) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
if (hasProvider)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
recipient.latestProviderLabel!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasAccount)
|
||||
Text(
|
||||
account!,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey.shade700,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContactLine(IconData icon, String text) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 13, color: Colors.grey.shade500),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMemberPopupMenu(RecipientGroupMember member) {
|
||||
return PopupMenuButton<String>(
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
color: Colors.grey.shade500,
|
||||
size: 20,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
splashRadius: 20,
|
||||
tooltip: 'More options',
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
onSelected: (value) async {
|
||||
if (value == 'remove') {
|
||||
await _confirmRemoveMember(member);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'remove',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.person_remove_outlined,
|
||||
size: 18, color: Colors.red),
|
||||
SizedBox(width: 10),
|
||||
Text(
|
||||
'Remove from group',
|
||||
style: TextStyle(
|
||||
color: Colors.red,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmRemoveMember(RecipientGroupMember member) async {
|
||||
final name = member.recipient.name ?? 'this member';
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
title: const Text('Remove member'),
|
||||
content: Text('Are you sure you want to remove $name from this group?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Remove'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true && mounted) {
|
||||
await groupController.removeMember(
|
||||
groupId: widget.group.id ?? '',
|
||||
recipientId: member.id ?? '',
|
||||
userId: widget.group.userId ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildMembersSkeletonList(ScrollController scrollController) {
|
||||
return ListView.separated(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
itemCount: 6,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, index) {
|
||||
return Skeletonizer(
|
||||
enabled: true,
|
||||
child: _buildMemberCard(
|
||||
RecipientGroupMember(
|
||||
recipientGroupId: '',
|
||||
recipient: Recipient(
|
||||
name: 'Loading Name Here',
|
||||
account: '0000000000',
|
||||
phoneNumber: '+000 000 0000',
|
||||
email: 'loading@example.com',
|
||||
latestProviderLabel: 'PROVIDER',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMembersEmptyState() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.08),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.people_alt_outlined,
|
||||
size: 44,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'No members yet',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Members added to this group will appear\nhere once they are available.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey.shade600,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNoSearchResultsState() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.search_off_outlined,
|
||||
size: 56,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'No matching members',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'No results for "$_memberQuery"',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _initialsFor(String name) {
|
||||
final cleaned = name.trim();
|
||||
if (cleaned.isEmpty) return '?';
|
||||
final parts = cleaned.split(RegExp(r'\s+'));
|
||||
if (parts.length == 1) {
|
||||
return parts.first.characters.first.toUpperCase();
|
||||
}
|
||||
return (parts.first.characters.first + parts.last.characters.first)
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
void _onDeleteSelected() async {
|
||||
final userId = widget.group.userId ?? '';
|
||||
final count = batchController.model.selectedBatchIds.length;
|
||||
@@ -352,6 +853,8 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_scrollController.dispose();
|
||||
_memberSearchController.dispose();
|
||||
_membersAnimController.dispose();
|
||||
groupController.dispose();
|
||||
batchController.dispose();
|
||||
super.dispose();
|
||||
@@ -468,6 +971,13 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
||||
onPressed: () => batchController.toggleSelectMode(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildIconButton(
|
||||
icon: Icons.refresh,
|
||||
tooltip: 'Refresh batches',
|
||||
isActive: false,
|
||||
onPressed: _loadData,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildIconButton(
|
||||
icon: Icons.add,
|
||||
tooltip: 'Create batch',
|
||||
|
||||
@@ -179,6 +179,13 @@ class _GroupsScreenState extends State<GroupsScreen> {
|
||||
onPressed: () => controller.toggleSelectMode(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildIconButton(
|
||||
icon: Icons.refresh,
|
||||
tooltip: 'Refresh groups',
|
||||
isActive: false,
|
||||
onPressed: _loadData,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildIconButton(
|
||||
icon: Icons.add,
|
||||
tooltip: 'Create new group',
|
||||
|
||||
Reference in New Issue
Block a user