added wallet functionality
This commit is contained in:
@@ -103,10 +103,7 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
||||
final batch = result.data!;
|
||||
context.pushReplacement('/groups/${widget.group.id}/batches/${batch.id}');
|
||||
} else {
|
||||
AppSnackBar.showError(
|
||||
context,
|
||||
result.error ?? 'Failed to create batch.',
|
||||
);
|
||||
AppSnackBar.showError(context, result.error ?? 'Failed to create batch.');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:qpay/screens/confirm/confirm_controller.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';
|
||||
@@ -27,6 +28,8 @@ class BatchDetailScreen extends StatefulWidget {
|
||||
class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||
with TickerProviderStateMixin {
|
||||
late BatchController controller;
|
||||
ConfirmController? _confirmController;
|
||||
GroupBatchUpdateRequest? _cachedUpdateReq;
|
||||
bool _showPollButton = false;
|
||||
String _userId = '';
|
||||
int _selectedTabIndex = 0;
|
||||
@@ -140,6 +143,7 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||
_headerAnimController.dispose();
|
||||
_cardAnimController.dispose();
|
||||
_contentAnimController.dispose();
|
||||
_confirmController?.dispose();
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -168,6 +172,9 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache the update request so _onRequest can check the processor
|
||||
_cachedUpdateReq = updateReq;
|
||||
|
||||
final result = await controller.confirmBatch(batch.id!, _userId);
|
||||
if (!mounted) return;
|
||||
if (!result.isSuccess) {
|
||||
@@ -446,21 +453,54 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||
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;
|
||||
}
|
||||
|
||||
// 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(); // refresh batch and items after request
|
||||
await _init();
|
||||
if (_successStatuses.contains(pollingStatus)) {
|
||||
if (mounted) {
|
||||
AppSnackBar.showSuccess(context, 'Payment completed successfully.');
|
||||
@@ -473,13 +513,185 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||
setState(() => _showPollButton = false);
|
||||
} else {
|
||||
setState(() => _showPollButton = true);
|
||||
_showError('Auto-poll failed. You can retry manually.');
|
||||
_showError(
|
||||
pollResult.error ?? 'Auto-poll failed. You can retry manually.',
|
||||
);
|
||||
}
|
||||
|
||||
await _init(); // refresh batch and items after request
|
||||
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;
|
||||
@@ -678,65 +890,6 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Status Badge
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
Widget _buildStatusBadge(String status) {
|
||||
final upper = status.toUpperCase();
|
||||
final bool isSuccess = _successStatuses.contains(upper);
|
||||
final bool isProcessing = upper == 'PROCESSING';
|
||||
final bool isPending = upper == 'CREATED' || upper == 'CONFIRMED_ALL';
|
||||
|
||||
final Color bgColor = isSuccess
|
||||
? const Color(0xFFD1FAE5)
|
||||
: isProcessing
|
||||
? const Color(0xFFDBEAFE)
|
||||
: isPending
|
||||
? const Color(0xFFFEF3C7)
|
||||
: const Color(0xFFFEE2E2);
|
||||
final Color textColor = isSuccess
|
||||
? const Color(0xFF065F46)
|
||||
: isProcessing
|
||||
? const Color(0xFF1E40AF)
|
||||
: isPending
|
||||
? const Color(0xFF92400E)
|
||||
: const Color(0xFF991B1B);
|
||||
final Color dotColor = isSuccess
|
||||
? const Color(0xFF10B981)
|
||||
: isProcessing
|
||||
? const Color(0xFF3B82F6)
|
||||
: isPending
|
||||
? const Color(0xFFF59E0B)
|
||||
: const Color(0xFFEF4444);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
status,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: textColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Refresh Button (sits next to status pill; reloads the batch)
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
@@ -836,7 +989,7 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildStatusBadge(status),
|
||||
StatusChip(status: status),
|
||||
const SizedBox(width: 8),
|
||||
_buildRefreshButton(theme, isDark),
|
||||
],
|
||||
@@ -878,7 +1031,10 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildDottedDivider(isDark),
|
||||
..._buildHeaderActionButtons(batch, theme),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [..._buildHeaderActionButtons(batch, theme)],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -403,7 +403,7 @@ class BatchController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<ApiResponse<GroupBatch>> pollBatch(
|
||||
Future<ApiResponse<TransactionModel>> pollBatch(
|
||||
String batchId,
|
||||
String userId,
|
||||
) async {
|
||||
@@ -418,11 +418,18 @@ class BatchController extends ChangeNotifier {
|
||||
body.toJson(),
|
||||
);
|
||||
final response = _unwrap(raw);
|
||||
final batch = GroupBatch.fromJson(response as Map<String, dynamic>);
|
||||
model.currentBatch = batch;
|
||||
final batchTransaction = TransactionModel.fromJson(
|
||||
response as Map<String, dynamic>,
|
||||
);
|
||||
model.isActing = false;
|
||||
if (!_disposed) notifyListeners();
|
||||
return ApiResponse.success(batch);
|
||||
if (response['status'] != 'FAILED') {
|
||||
return ApiResponse.success(batchTransaction);
|
||||
} else {
|
||||
return ApiResponse.failure(
|
||||
response['errorMessage'] ?? "Transaction failed",
|
||||
);
|
||||
}
|
||||
} catch (e, s) {
|
||||
logger.e(e);
|
||||
logger.e(s);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qpay/screens/groups/models/group_batch_model.dart';
|
||||
import 'package:intl/intl.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';
|
||||
@@ -108,10 +109,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||||
return Column(
|
||||
children: [
|
||||
_buildMembersSheetHandle(),
|
||||
_buildMembersSheetHeader(
|
||||
setSheetState,
|
||||
scrollController,
|
||||
),
|
||||
_buildMembersSheetHeader(setSheetState, scrollController),
|
||||
Expanded(
|
||||
child: ListenableBuilder(
|
||||
listenable: groupController,
|
||||
@@ -119,9 +117,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||||
final allMembers = groupController.model.members;
|
||||
final filtered = _filteredMembers(allMembers);
|
||||
if (groupController.model.isLoading) {
|
||||
return _buildMembersSkeletonList(
|
||||
scrollController,
|
||||
);
|
||||
return _buildMembersSkeletonList(scrollController);
|
||||
}
|
||||
if (allMembers.isEmpty) {
|
||||
return _buildMembersEmptyState();
|
||||
@@ -185,10 +181,9 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.1),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
@@ -237,10 +232,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search members...',
|
||||
hintStyle: TextStyle(
|
||||
color: Colors.grey.shade500,
|
||||
fontSize: 14,
|
||||
),
|
||||
hintStyle: TextStyle(color: Colors.grey.shade500, fontSize: 14),
|
||||
prefixIcon: Icon(
|
||||
Icons.search,
|
||||
color: Colors.grey.shade600,
|
||||
@@ -301,19 +293,17 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
);
|
||||
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(
|
||||
@@ -350,10 +340,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 14,
|
||||
horizontal: 14,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 14),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -411,10 +398,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Theme.of(context).colorScheme.primary,
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.7),
|
||||
Theme.of(context).colorScheme.primary.withValues(alpha: 0.7),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
@@ -422,10 +406,9 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.25),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.25),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
@@ -444,15 +427,13 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMemberSubtitleRow(
|
||||
Recipient recipient,
|
||||
String? memberAccount,
|
||||
) {
|
||||
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 &&
|
||||
final hasProvider =
|
||||
recipient.latestProviderLabel != null &&
|
||||
recipient.latestProviderLabel!.isNotEmpty;
|
||||
if (!hasAccount && !hasProvider) {
|
||||
return const SizedBox.shrink();
|
||||
@@ -464,15 +445,11 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||||
children: [
|
||||
if (hasProvider)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.08),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
@@ -507,10 +484,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||||
Flexible(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
@@ -521,17 +495,11 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||||
|
||||
Widget _buildMemberPopupMenu(RecipientGroupMember member) {
|
||||
return PopupMenuButton<String>(
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
color: Colors.grey.shade500,
|
||||
size: 20,
|
||||
),
|
||||
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),
|
||||
),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
onSelected: (value) async {
|
||||
if (value == 'remove') {
|
||||
await _confirmRemoveMember(member);
|
||||
@@ -542,8 +510,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||||
value: 'remove',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.person_remove_outlined,
|
||||
size: 18, color: Colors.red),
|
||||
Icon(Icons.person_remove_outlined, size: 18, color: Colors.red),
|
||||
SizedBox(width: 10),
|
||||
Text(
|
||||
'Remove from group',
|
||||
@@ -564,9 +531,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
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: [
|
||||
@@ -628,10 +593,9 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.08),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.08),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
@@ -690,10 +654,7 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||||
Text(
|
||||
'No results for "$_memberQuery"',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1163,6 +1124,8 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||||
final inSelectMode = batchController.model.selectMode;
|
||||
|
||||
final status = batch.status ?? 'UNKNOWN';
|
||||
final createdAt = batch.createdAt;
|
||||
final parsedDate = createdAt != null ? DateTime.tryParse(createdAt) : null;
|
||||
return Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -1227,25 +1190,44 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
if (batch.count != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
_countPill('${batch.count}', Colors.blue),
|
||||
const SizedBox(width: 3),
|
||||
_countPill(
|
||||
'${batch.successfulCount ?? 0} ok',
|
||||
Colors.green,
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (parsedDate != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
DateFormat.yMMMd().add_jm().format(parsedDate),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
_countPill(
|
||||
'${batch.failedCount ?? 0} failed',
|
||||
Colors.red,
|
||||
),
|
||||
if (batch.count != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
_statIcon(
|
||||
Icons.people_outline,
|
||||
'${batch.count}',
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_statIcon(
|
||||
Icons.check_circle_outline,
|
||||
'${batch.successfulCount ?? 0}',
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
_statIcon(
|
||||
Icons.cancel_outlined,
|
||||
'${batch.failedCount ?? 0}',
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -1258,22 +1240,21 @@ class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _countPill(String label, Color color) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withAlpha(20),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: color.withAlpha(60)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
Widget _statIcon(IconData icon, String label) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 12, color: Colors.grey.shade500),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Colors.grey.shade600,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user