progress on batches

This commit is contained in:
2026-06-05 20:25:20 +02:00
parent eab2368331
commit 2dd790fe6e
23 changed files with 9112 additions and 74 deletions

4225
QPay.postman_collection.json Normal file

File diff suppressed because it is too large Load Diff

83
group-batches.md Normal file
View File

@@ -0,0 +1,83 @@
# Plan: Groups & Batches Feature
## API Surface (from Postman)
### Groups
- `GET /api/public/recipient-groups?userId={userId}` — list groups
- `GET /api/public/recipient-groups/members?recipientGroupId={id}` — list members
- `POST /api/public/recipient-groups` — create group
- Body: `{ name, description, userId, recipients: [{name, phoneNumber, latestProviderLabel, account}] }`
### Batches
- `GET /api/public/group-batches?recipientGroupId={id}&sort=createdAt,desc` — list batches
- `GET /api/public/group-batches/items?groupBatchId={id}&sort=createdAt,desc` — list batch items
- `POST /api/public/group-batches` — create batch (recipientGroupId, userId, currency, amount, paymentProcessorLabel, paymentProcessorName, paymentProcessorImage, transactionTemplate)
- `POST /api/public/group-batches/{batchId}/confirm` — confirm batch
- `POST /api/public/group-batches/{batchId}/request` — request batch (payment)
- `POST /api/public/group-batches/{batchId}/poll` — poll batch status
## Files To Create
### Phase 1: Models
- `lib/models/recipient_group_model.dart` — RecipientGroup, RecipientMember, CreateGroupRequest
- `lib/models/group_batch_model.dart` — GroupBatch, GroupBatchItem, CreateBatchRequest (transactionTemplate typed as TransactionModel from lib/screens/transactions/transaction_model.dart), BatchActionRequest. No separate TransactionTemplate class needed.
### Phase 2: Controllers
- `lib/screens/groups/group_controller.dart` — ChangeNotifier, list/create groups, list members
- `lib/screens/groups/batch_controller.dart` — ChangeNotifier, list/create batches, list items, confirm/request/poll
### Phase 3: Screens
- `lib/screens/groups/groups_screen.dart` — list all groups
- `lib/screens/groups/group_create_screen.dart` — create group form (name, description, multiline textarea: one recipient per line as `name,account`; phoneNumber defaults to account value)
- `lib/screens/groups/group_detail_screen.dart` — tabbed view: Batches first (default), Members second; receives RecipientGroup via extra; FAB on Batches tab to create new batch
- `lib/screens/groups/batch_create_screen.dart` — create batch form (provider, processor, amount, currency); on success navigate to BatchDetailScreen passing created batch via extra
- `lib/screens/groups/batch_detail_screen.dart` — batch items list + Confirm/Request/Poll action buttons
- `lib/screens/groups/batch_item_screen.dart` — single batch item detail view
### Phase 4: Integration
- `lib/navbar.dart` — add Groups as 3rd nav item (index 2)
- `lib/main.dart` — add routes + GroupController + BatchController providers
## Routes
- `/groups` → GroupsScreen
- `/groups/create` → GroupCreateScreen
- `/groups/:groupId` → GroupDetailScreen
- `/groups/:groupId/batches/create` → BatchCreateScreen
- `/groups/:groupId/batches/:batchId` → BatchDetailScreen
- `/groups/:groupId/batches/:batchId/items/:itemId` → BatchItemScreen
## Action Return Types
- `createBatch``ApiResponse<GroupBatch>`
- `confirmBatch``ApiResponse<GroupBatch>`
- `requestBatch``ApiResponse<TransactionModel>` (reuses TransactionModel)
- `pollBatch``ApiResponse<GroupBatch>`
- List endpoints → `ApiResponse<PageableModel<T>>`
## BatchDetailScreen Auto-Poll Logic
After **Request** action:
1. `requestBatch()` returns `TransactionModel`
2. Check `transaction.pollingStatus` — if successful (e.g. `SUCCESS`, `COMPLETE`) → no poll needed, refresh batch
3. Otherwise → auto-trigger `pollBatch()` immediately without user input
4. If auto-poll fails (network error / non-terminal status) → show **Poll** button for manual retry
5. Batch status from poll response drives UI state (show/hide action buttons)
## Key Patterns
- Controllers extend ChangeNotifier
- Models use `@JsonSerializable` with generated `.g.dart` files
- All network calls use `ApiResponse<T>` wrapping pattern (try/catch in controller)
- Use `ListenableBuilder` in screens
- Read `userId`/`token` from SharedPreferences
- Pass extra data via GoRouter `extra:` parameter
- `idempotencyKey` for batch actions generated as `"batch-{batchId}-{action}-{timestamp}"`
- Navbar: add Groups at index 2, update `_handleTap` + `_calculateSelectedIndex`
## Member Input Parsing (group_create_screen)
- One recipient per line: `name,account`
- `phoneNumber` defaults to `account` value
- `latestProviderLabel` defaults to `""`
## Decisions
- `TransactionModel` reused as-is for `transactionTemplate` field — no wrapper class
- No pagination on groups/batches lists in this iteration
- No edit/delete for groups or batches in scope
- Poll button only shown as manual fallback after auto-poll failure

View File

@@ -43,4 +43,24 @@ class Http {
logger.i(response.data.toString());
return response.data;
}
Future<dynamic> put(String url, dynamic data) async {
Map<String, String> headers = {'Content-Type': 'application/json'};
Response response;
response = await dio.put(
baseUrl + url,
data: data,
options: Options(headers: headers),
);
logger.i(response.data.toString());
return response.data;
}
Future<dynamic> delete(String url) async {
Response response;
response = await dio.delete(baseUrl + url);
logger.i(response.data.toString());
return response.data;
}
}

View File

@@ -25,6 +25,14 @@ import 'package:qpay/screens/receipt/receipt_screen.dart';
import 'package:qpay/screens/recipient/recipients_screen.dart';
import 'package:qpay/screens/transaction_controller.dart';
import 'package:qpay/screens/profile_screen.dart';
import 'package:qpay/screens/groups/groups_screen.dart';
import 'package:qpay/screens/groups/group_create_screen.dart';
import 'package:qpay/screens/groups/group_detail_screen.dart';
import 'package:qpay/screens/groups/batch_create_screen.dart';
import 'package:qpay/screens/groups/batch_detail_screen.dart';
import 'package:qpay/screens/groups/batch_item_screen.dart';
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
import 'package:qpay/screens/groups/models/group_batch_model.dart';
import 'package:qpay/theme/app_theme.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:firebase_core/firebase_core.dart';
@@ -49,6 +57,7 @@ String resolveEnvFile(String env) {
/// navigation to the originally intended URL.
class SplashState extends ChangeNotifier {
bool _complete = false;
/// The route the user originally intended to visit before being
/// redirected to /splash. Set by the redirect callback on first
/// invocation.
@@ -188,9 +197,8 @@ class _MyAppState extends State<MyApp> {
),
GoRoute(
path: '/poll/:id',
builder: (context, state) => PollScreen(
transactionId: state.pathParameters['id'],
),
builder: (context, state) =>
PollScreen(transactionId: state.pathParameters['id']),
),
GoRoute(
path: '/poll',
@@ -222,6 +230,43 @@ class _MyAppState extends State<MyApp> {
path: '/profile',
builder: (context, state) => const ProfileScreen(),
),
GoRoute(
path: '/groups',
builder: (context, state) => const GroupsScreen(),
),
GoRoute(
path: '/groups/create',
builder: (context, state) => const GroupCreateScreen(),
),
GoRoute(
path: '/groups/:groupId',
builder: (context, state) {
final group = state.extra as RecipientGroup;
return GroupDetailScreen(group: group);
},
),
GoRoute(
path: '/groups/:groupId/batches/create',
builder: (context, state) {
final group = state.extra as RecipientGroup;
return BatchCreateScreen(group: group);
},
),
GoRoute(
path: '/groups/:groupId/batches/:batchId',
builder: (context, state) {
final batchId = state.pathParameters['batchId']!;
final groupId = state.pathParameters['groupId']!;
return BatchDetailScreen(batchId: batchId, groupId: groupId);
},
),
GoRoute(
path: '/groups/:groupId/batches/:batchId/items/:itemId',
builder: (context, state) {
final item = state.extra as GroupBatchItem;
return BatchItemScreen(item: item);
},
),
GoRoute(
path: '/onboarding/landing',
builder: (context, state) => const LandingScreen(),

View File

@@ -47,7 +47,7 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
extended: true,
leading: Image(
image: AssetImage('assets/velocity.png'),
width: 150
width: 150,
),
trailing: _buildLogin(context),
labelType: NavigationRailLabelType.none,
@@ -121,6 +121,11 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
"selectedIcon": Icon(Icons.home),
"label": 'Home',
},
{
"icon": Icon(Icons.group_outlined),
"selectedIcon": Icon(Icons.group),
"label": 'Groups',
},
{
"icon": Icon(Icons.history_outlined),
"selectedIcon": Icon(Icons.history),
@@ -130,7 +135,7 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
}
Widget _buildLogin(BuildContext context) {
if(_isLoggedIn) {
if (_isLoggedIn) {
return SizedBox();
}
@@ -184,23 +189,19 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
case 0:
context.go('/home');
break;
// case 1:
// context.go('/recipients');
// break;
case 1:
context.go('/groups');
break;
case 2:
context.go('/history');
break;
// case 2:
// context.go('/profile');
// break;
}
}
int _calculateSelectedIndex(BuildContext context) {
final String location = GoRouterState.of(context).uri.path;
// if (location.startsWith('/recipients')) return 1;
if (location.startsWith('/history')) return 1;
// if (location.startsWith('/profile')) return 2;
if (location.startsWith('/groups')) return 1;
if (location.startsWith('/history')) return 2;
return 0;
}
}

View File

@@ -0,0 +1,478 @@
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/recipient_group_model.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
import 'package:qpay/screens/home/home_controller.dart';
import 'package:qpay/screens/pay/pay_controller.dart';
import 'package: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 controller;
final _formKey = GlobalKey<FormState>();
final _descriptionController = TextEditingController();
final _amountController = TextEditingController();
final _debitPhoneController = TextEditingController();
String _currency = 'USD';
bool _loadingDropdowns = true;
@override
void initState() {
super.initState();
controller = BatchController(context);
_init();
}
Future<void> _init() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.containsKey('phone')) {
_debitPhoneController.text = prefs.getString('phone')!;
}
await Future.wait([controller.loadProviders()]);
if (mounted) setState(() => _loadingDropdowns = false);
}
@override
void dispose() {
controller.dispose();
_descriptionController.dispose();
_amountController.dispose();
_debitPhoneController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
final selectedProvider = controller.model.selectedProvider;
if (selectedProvider == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('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.')),
);
return;
}
final prefs = await SharedPreferences.getInstance();
final userId = prefs.getString('userId') ?? '';
final transactionTemplate = <String, dynamic>{
'type': 'CONFIRM',
'billClientId': selectedProvider.clientId,
'debitPhone': _debitPhoneController.text.trim(),
'debitRef': 'Velocity',
'debitCurrency': _currency,
'billName': selectedProvider.name,
'providerImage': selectedProvider.image,
'providerLabel': selectedProvider.label,
'creditName': '',
'creditEmail': '',
'productUid': '',
'billProductName': '',
'region': 'ZW',
};
final req = CreateBatchRequest(
recipientGroupId: widget.group.id ?? '',
userId: userId,
currency: _currency,
description: _descriptionController.text.trim(),
amount: amount,
transactionTemplate: transactionTemplate,
);
final result = await controller.createBatch(req);
if (!mounted) return;
if (result.isSuccess) {
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.')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('New Batch'), centerTitle: true),
body: ListenableBuilder(
listenable: controller,
builder: (context, _) {
if (_loadingDropdowns) {
return const Center(child: CircularProgressIndicator());
}
return Center(
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: [
_buildSectionLabel('Bill Provider'),
const SizedBox(height: 8),
_buildProviderGrid(constraints),
const SizedBox(height: 16),
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(height: 16),
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
controller: _amountController,
keyboardType:
const TextInputType.numberWithOptions(
decimal: true,
),
textInputAction: TextInputAction.next,
decoration: InputDecoration(
labelText:
'Amount each recipient will receive',
hintText: 'Amount',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (v) =>
(v == null || v.trim().isEmpty)
? 'Required'
: null,
),
),
const SizedBox(width: 12),
Expanded(
child: DropdownButtonFormField<String>(
value: _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)
setState(() => _currency = v);
},
),
),
],
),
const SizedBox(height: 16),
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: controller.model.isActing
? null
: _submit,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: controller.model.isActing
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'Create Batch',
style: TextStyle(fontSize: 16),
),
),
],
),
),
),
);
},
),
);
},
),
);
}
Widget _buildSectionLabel(String text) {
return Text(
text,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.grey.shade700,
),
);
}
static const Map<String, Color> _categoryColorMap = {
'ECONET': Color(0xFF1A73E8),
'NETONE': Color.fromARGB(255, 230, 127, 0),
'TELECEL': Color(0xFF7B1FA2),
'ZESA': Color.fromARGB(255, 230, 58, 0),
};
Color _resolveProviderColor(BillProvider provider) {
final exact = _categoryColorMap[provider.category.toUpperCase()];
if (exact != null) return exact;
final labelMatch = _categoryColorMap[provider.label.toUpperCase()];
if (labelMatch != null) return labelMatch;
final name = provider.name.toUpperCase();
for (final entry in _categoryColorMap.entries) {
if (name.contains(entry.key)) return entry.value;
}
final hash = provider.clientId.hashCode;
final hue = (hash % 360).toDouble();
return HSVColor.fromAHSV(1.0, hue, 0.75, 0.85).toColor();
}
Widget _buildProviderGrid(BoxConstraints constraints) {
if (controller.model.providers.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 providers available',
style: TextStyle(color: Colors.grey.shade600),
),
);
}
final isDark = Theme.of(context).brightness == Brightness.dark;
return LayoutBuilder(
builder: (context, gridConstraints) {
// gridConstraints.maxWidth reflects the actual rendered width of this widget
// 3-column grid: subtract 2 gaps (10*2 = 20), then divide by 3
final cardWidth = (gridConstraints.maxWidth - 20) / 3;
return Wrap(
spacing: 10,
runSpacing: 10,
children: controller.model.providers
.map(
(provider) => _buildProviderCard(provider, isDark, cardWidth),
)
.toList(),
);
},
);
}
Widget _buildProviderCard(BillProvider provider, bool isDark, double width) {
final isSelected =
controller.model.selectedProvider?.clientId == provider.clientId;
final accent = _resolveProviderColor(provider);
return SizedBox(
width: width,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => controller.selectProvider(provider),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E1E1E) : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? accent
: isDark
? Colors.white.withValues(alpha: 0.1)
: Colors.black.withValues(alpha: 0.06),
width: isSelected ? 1.5 : 1,
),
),
child: Stack(
children: [
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 28,
height: 28,
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Image(
image: AssetImage("assets/${provider.image}"),
errorBuilder: (_, __, ___) => Icon(
Icons.payment_rounded,
size: 14,
color: accent,
),
),
),
],
),
const SizedBox(height: 8),
Text(
provider.name,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : Colors.black87,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
provider.description,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w400,
color: isDark ? Colors.white54 : Colors.grey.shade500,
height: 1.3,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
if (isSelected)
Positioned(
top: -2,
right: -2,
child: Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: accent,
shape: BoxShape.circle,
border: Border.all(
color: isDark
? const Color(0xFF1E1E1E)
: Colors.white,
width: 1.5,
),
),
child: const Icon(
Icons.check_rounded,
size: 11,
color: Colors.white,
),
),
),
],
),
),
),
),
);
}
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);
},
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import 'package:qpay/screens/groups/models/group_batch_model.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/widgets/status_chip_widget.dart';
class BatchItemScreen extends StatelessWidget {
final GroupBatchItem item;
const BatchItemScreen({super.key, required this.item});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(item.recipientName ?? 'Batch Item'),
centerTitle: true,
),
body: Center(
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: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeaderCard(context),
const SizedBox(height: 16),
_buildDetailsCard(),
],
),
),
);
},
),
),
);
}
Widget _buildHeaderCard(BuildContext context) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.black12.withAlpha(30)),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
CircleAvatar(
radius: 32,
backgroundColor: Theme.of(
context,
).colorScheme.primary.withAlpha(30),
child: Text(
item.recipientName?.isNotEmpty == true
? item.recipientName![0].toUpperCase()
: '?',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
),
const SizedBox(height: 12),
Text(
item.recipientName ?? '',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
item.recipientPhone ?? '',
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
),
const SizedBox(height: 12),
StatusChip(status: item.status ?? 'PENDING'),
],
),
),
);
}
Widget _buildDetailsCard() {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.black12.withAlpha(30)),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Details',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
_detailRow('Amount', _formatAmount()),
_detailRow('Status', item.status ?? ''),
if (item.transactionId != null)
_detailRow('Transaction ID', item.transactionId!),
if (item.groupBatchId != null)
_detailRow('Batch ID', item.groupBatchId!),
if (item.createdAt != null) _detailRow('Created', item.createdAt!),
if (item.errorMessage != null && item.errorMessage!.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.withAlpha(20),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.withAlpha(60)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Error',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.red,
fontSize: 13,
),
),
const SizedBox(height: 4),
Text(
item.errorMessage!,
style: const TextStyle(fontSize: 13, color: Colors.red),
),
],
),
),
],
],
),
),
);
}
String _formatAmount() {
if (item.amount == null) return '';
return item.amount!.toStringAsFixed(2);
}
Widget _detailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
),
Expanded(child: Text(value, style: const TextStyle(fontSize: 13))),
],
),
);
}
}

View File

@@ -0,0 +1,495 @@
import 'package:flutter/material.dart';
import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:qpay/screens/groups/models/group_batch_model.dart';
import 'package:qpay/models/pageable_model.dart';
import 'package:qpay/screens/home/home_controller.dart';
import 'package:qpay/screens/pay/pay_controller.dart';
import 'package:qpay/screens/transactions/transaction_model.dart';
class BatchScreenModel {
List<GroupBatch> batches = [];
List<GroupBatchItem> batchItems = [];
GroupBatch? currentBatch;
List<BillProvider> providers = [];
List<PaymentProcessor> processors = [];
BillProvider? selectedProvider;
PaymentProcessor? selectedProcessor;
bool isLoading = false;
bool isLoadingMore = false;
bool isActing = false;
String? errorMessage;
int currentPage = 0;
int totalPages = 0;
int totalElements = 0;
String searchQuery = '';
String? statusFilter;
String? currencyFilter;
bool selectMode = false;
Set<String> selectedBatchIds = {};
}
class BatchController extends ChangeNotifier {
final BatchScreenModel model = BatchScreenModel();
final Http http = Http();
BuildContext context;
bool _disposed = false;
BatchController(this.context);
@override
void dispose() {
_disposed = true;
super.dispose();
}
dynamic _unwrap(dynamic response) {
if (response is Map && response.containsKey('body')) {
return response['body'];
}
return response;
}
String buildQueryParameters(Map<String, String> params) {
if (params.isEmpty) {
return '';
}
String query = '';
params.forEach((key, value) {
query += '$key=$value&';
});
return query.substring(0, query.length - 1);
}
Future<ApiResponse<List<BillProvider>>> loadProviders() async {
try {
final raw = await http.get(
'/public/providers?currency=USD&sort=priority,asc',
);
final response = _unwrap(raw);
final List<dynamic> content;
if (response is Map && response.containsKey('content')) {
content = response['content'] as List<dynamic>;
} else if (response is List) {
content = response;
} else {
content = [];
}
model.providers = content
.map((e) => BillProvider.fromJson(e as Map<String, dynamic>))
.toList();
if (model.providers.isNotEmpty) {
model.selectedProvider = model.providers.first;
}
if (!_disposed) notifyListeners();
return ApiResponse.success(model.providers);
} catch (e) {
logger.e(e);
return ApiResponse.failure('Failed to load providers');
}
}
Future<ApiResponse<List<PaymentProcessor>>> loadProcessors() async {
try {
final raw = await http.get('/public/payment-processors');
final response = _unwrap(raw);
final List<dynamic> content = response is List
? response
: (response['content'] ?? []);
model.processors = content
.map((e) => PaymentProcessor.fromJson(e as Map<String, dynamic>))
.toList();
if (model.processors.isNotEmpty) {
model.selectedProcessor = model.processors.first;
}
if (!_disposed) notifyListeners();
return ApiResponse.success(model.processors);
} catch (e) {
logger.e(e);
return ApiResponse.failure('Failed to load processors');
}
}
Future<ApiResponse<List<GroupBatch>>> listBatches(
String groupId, {
int page = 0,
int size = 20,
String? search,
String? status,
String? currency,
}) async {
if (page == 0) {
model.isLoading = true;
model.batches = [];
model.currentPage = 0;
} else {
model.isLoadingMore = true;
}
if (!_disposed) notifyListeners();
try {
final params = <String, String>{
'recipientGroupId': groupId,
'page': page.toString(),
'size': size.toString(),
'sort': 'createdAt,desc',
};
if (search != null && search.isNotEmpty) {
params['description'] = search;
}
if (status != null && status.isNotEmpty) {
params['status'] = status;
}
if (currency != null && currency.isNotEmpty) {
params['currency'] = currency;
}
final raw = await http.get(
'/public/group-batches?${buildQueryParameters(params)}',
);
final response = _unwrap(raw);
final pageableModel = PageableModel.fromJson(
response as Map<String, dynamic>,
);
final newBatches = pageableModel.content
.map((e) => GroupBatch.fromJson(e as Map<String, dynamic>))
.toList();
if (page == 0) {
model.batches = newBatches;
} else {
model.batches.addAll(newBatches);
}
model.currentPage = pageableModel.number;
model.totalPages = pageableModel.totalPages;
model.totalElements = pageableModel.totalElements;
model.isLoading = false;
model.isLoadingMore = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(model.batches);
} catch (e) {
logger.e(e);
model.errorMessage = e.toString();
if (page == 0) {
model.batches = [];
}
model.isLoading = false;
model.isLoadingMore = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to load batches');
}
}
Future<void> searchBatches(
String groupId, {
String? search,
String? status,
String? currency,
}) async {
model.searchQuery = search ?? '';
model.statusFilter = status;
model.currencyFilter = currency;
await listBatches(
groupId,
page: 0,
search: search,
status: status,
currency: currency,
);
}
Future<void> loadNextPage(String groupId) async {
if (model.isLoadingMore || model.currentPage + 1 >= model.totalPages) {
return;
}
await listBatches(
groupId,
page: model.currentPage + 1,
search: model.searchQuery.isNotEmpty ? model.searchQuery : null,
status: model.statusFilter,
currency: model.currencyFilter,
);
}
Future<ApiResponse<List<GroupBatchItem>>> listBatchItems(
String batchId,
) async {
model.isLoading = true;
if (!_disposed) notifyListeners();
try {
final raw = await http.get(
'/public/group-batches/items?groupBatchId=$batchId&sort=createdAt,desc',
);
final response = _unwrap(raw);
final List<dynamic> content;
if (response is Map && response.containsKey('content')) {
content = PageableModel.fromJson(
response as Map<String, dynamic>,
).content;
} else if (response is List) {
content = response;
} else {
content = [];
}
model.batchItems = content
.map((e) => GroupBatchItem.fromJson(e as Map<String, dynamic>))
.toList();
model.isLoading = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(model.batchItems);
} catch (e) {
logger.e(e);
model.errorMessage = e.toString();
model.batchItems = [];
model.isLoading = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to load batch items');
}
}
Future<ApiResponse<GroupBatch>> getBatch(
String batchId,
String userId,
) async {
model.isActing = true;
if (!_disposed) notifyListeners();
try {
final raw = await http.get(
'/public/group-batches/$batchId?userId=$userId',
);
final response = _unwrap(raw);
final batch = GroupBatch.fromJson(response as Map<String, dynamic>);
model.currentBatch = batch;
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(batch);
} catch (e) {
logger.e(e);
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to load batch');
}
}
Future<ApiResponse<GroupBatch>> createBatch(CreateBatchRequest req) async {
model.isActing = true;
if (!_disposed) notifyListeners();
try {
final raw = await http.post('/public/group-batches', req.toJson());
final response = _unwrap(raw);
final batch = GroupBatch.fromJson(response as Map<String, dynamic>);
model.currentBatch = batch;
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(batch);
} catch (e, s) {
logger.e(e);
logger.e(s);
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to create batch');
}
}
Future<ApiResponse<GroupBatch>> updateBatch(
GroupBatchUpdateRequest request,
) async {
model.isActing = true;
if (!_disposed) notifyListeners();
try {
final raw = await http.put(
'/public/group-batches/${request.id}',
request.toJson(),
);
final response = _unwrap(raw);
final batch = GroupBatch.fromJson(response as Map<String, dynamic>);
// Update the batch in the local list if present
final index = model.batches.indexWhere((b) => b.id == batch.id);
if (index != -1) {
model.batches[index] = batch;
}
model.currentBatch = batch;
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(batch);
} catch (e, s) {
logger.e(e);
logger.e(s);
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to update batch');
}
}
Future<ApiResponse<GroupBatch>> confirmBatch(
String batchId,
String userId,
) async {
model.isActing = true;
if (!_disposed) notifyListeners();
try {
final key =
'batch-$batchId-confirm-${DateTime.now().millisecondsSinceEpoch}';
final body = BatchActionRequest(userId: userId, idempotencyKey: key);
final raw = await http.post(
'/public/group-batches/$batchId/confirm',
body.toJson(),
);
final response = _unwrap(raw);
final batch = GroupBatch.fromJson(response as Map<String, dynamic>);
model.currentBatch = batch;
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(batch);
} catch (e) {
logger.e(e);
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to confirm batch');
}
}
Future<ApiResponse<TransactionModel>> requestBatch(
String batchId,
String userId,
) async {
model.isActing = true;
if (!_disposed) notifyListeners();
try {
final key =
'batch-$batchId-request-${DateTime.now().millisecondsSinceEpoch}';
final body = BatchActionRequest(userId: userId, idempotencyKey: key);
final raw = await http.post(
'/public/group-batches/$batchId/request',
body.toJson(),
);
final response = _unwrap(raw);
final txn = TransactionModel.fromJson(response as Map<String, dynamic>);
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(txn);
} catch (e, s) {
logger.e(e);
logger.e(s);
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to request batch payment');
}
}
Future<ApiResponse<Map<String, dynamic>>> downloadBatchReport(
String batchId,
String userId,
) async {
model.isActing = true;
if (!_disposed) notifyListeners();
try {
final raw = await http.get(
'/public/group-batches/$batchId/report?userId=$userId',
);
final response = _unwrap(raw);
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(response as Map<String, dynamic>);
} catch (e, s) {
logger.e(e);
logger.e(s);
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to download batch report');
}
}
Future<ApiResponse<GroupBatch>> pollBatch(
String batchId,
String userId,
) async {
model.isActing = true;
if (!_disposed) notifyListeners();
try {
final key =
'batch-$batchId-poll-${DateTime.now().millisecondsSinceEpoch}';
final body = BatchActionRequest(userId: userId, idempotencyKey: key);
final raw = await http.post(
'/public/group-batches/$batchId/poll',
body.toJson(),
);
final response = _unwrap(raw);
final batch = GroupBatch.fromJson(response as Map<String, dynamic>);
model.currentBatch = batch;
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(batch);
} catch (e, s) {
logger.e(e);
logger.e(s);
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to poll batch');
}
}
Future<void> deleteBatch({
required String batchId,
required String userId,
}) async {
try {
await http.delete('/public/group-batches/$batchId?userId=$userId');
model.batches.removeWhere((b) => b.id == batchId);
model.selectedBatchIds.remove(batchId);
model.currentBatch = null;
if (!_disposed) notifyListeners();
} catch (e) {
logger.e(e);
model.errorMessage = e.toString();
if (!_disposed) notifyListeners();
}
}
Future<void> deleteSelectedBatches({
required String groupId,
required String userId,
}) async {
final idsToDelete = List<String>.from(model.selectedBatchIds);
model.selectMode = false;
model.selectedBatchIds.clear();
if (!_disposed) notifyListeners();
for (final id in idsToDelete) {
await deleteBatch(batchId: id, userId: userId);
}
// Refresh after deletion
await listBatches(groupId, page: 0);
}
void selectProvider(BillProvider provider) {
model.selectedProvider = provider;
if (!_disposed) notifyListeners();
}
void selectProcessor(PaymentProcessor processor) {
model.selectedProcessor = processor;
if (!_disposed) notifyListeners();
}
void toggleSelectMode() {
model.selectMode = !model.selectMode;
if (!model.selectMode) {
model.selectedBatchIds.clear();
}
if (!_disposed) notifyListeners();
}
void toggleBatchSelection(String batchId) {
if (model.selectedBatchIds.contains(batchId)) {
model.selectedBatchIds.remove(batchId);
} else {
model.selectedBatchIds.add(batchId);
}
if (!_disposed) notifyListeners();
}
}

View File

@@ -0,0 +1,255 @@
import 'package:flutter/material.dart';
import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:qpay/models/pageable_model.dart';
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
class GroupScreenModel {
List<RecipientGroup> groups = [];
List<RecipientGroupMember> members = [];
bool isLoading = false;
bool isLoadingMore = false;
String? errorMessage;
int currentPage = 0;
int totalPages = 0;
int totalElements = 0;
String searchQuery = '';
bool selectMode = false;
Set<String> selectedGroupIds = {};
}
class GroupController extends ChangeNotifier {
final GroupScreenModel model = GroupScreenModel();
final Http http = Http();
BuildContext context;
bool _disposed = false;
GroupController(this.context);
@override
void dispose() {
_disposed = true;
super.dispose();
}
dynamic _unwrap(dynamic response) {
if (response is Map && response.containsKey('body')) {
return response['body'];
}
return response;
}
String buildQueryParameters(Map<String, String> params) {
if (params.isEmpty) {
return '';
}
String query = '';
params.forEach((key, value) {
query += '$key=$value&';
});
return query.substring(0, query.length - 1);
}
Future<ApiResponse<PageableModel>> listGroups({
required String userId,
int page = 0,
int size = 20,
String? name,
}) async {
if (page == 0) {
model.isLoading = true;
model.groups = [];
model.currentPage = 0;
} else {
model.isLoadingMore = true;
}
if (!_disposed) notifyListeners();
try {
final params = <String, String>{
'userId': userId,
'page': page.toString(),
'size': size.toString(),
};
if (name != null && name.isNotEmpty) {
params['name'] = name;
}
final raw = await http.get(
'/public/recipient-groups?${buildQueryParameters(params)}',
);
final response = _unwrap(raw);
final pageableModel =
PageableModel.fromJson(response as Map<String, dynamic>);
final newGroups = pageableModel.content
.map((e) => RecipientGroup.fromJson(e as Map<String, dynamic>))
.toList();
if (page == 0) {
model.groups = newGroups;
} else {
model.groups.addAll(newGroups);
}
model.currentPage = pageableModel.number;
model.totalPages = pageableModel.totalPages;
model.totalElements = pageableModel.totalElements;
model.isLoading = false;
model.isLoadingMore = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(pageableModel);
} catch (e) {
logger.e(e);
model.errorMessage = e.toString();
if (page == 0) {
model.groups = [];
}
model.isLoading = false;
model.isLoadingMore = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to load groups');
}
}
Future<void> searchGroups({
required String userId,
String? name,
}) async {
model.searchQuery = name ?? '';
await listGroups(userId: userId, page: 0, name: name);
}
Future<void> loadNextPage({required String userId}) async {
if (model.isLoadingMore || model.currentPage + 1 >= model.totalPages) {
return;
}
await listGroups(
userId: userId,
page: model.currentPage + 1,
name: model.searchQuery.isNotEmpty ? model.searchQuery : null,
);
}
Future<ApiResponse<List<RecipientGroupMember>>> listMembers(
String groupId,
) async {
model.isLoading = true;
if (!_disposed) notifyListeners();
try {
final raw = await http.get(
'/public/recipient-groups/members?recipientGroupId=$groupId',
);
final response = _unwrap(raw);
final List<dynamic> content;
if (response is List) {
content = response;
} else if (response is Map && response.containsKey('content')) {
content = response['content'] as List<dynamic>;
} else {
content = [];
}
model.members = content
.map((e) => RecipientGroupMember.fromJson(e as Map<String, dynamic>))
.toList();
model.isLoading = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(model.members);
} catch (e, s) {
logger.e(e);
logger.e(s);
model.errorMessage = e.toString();
model.members = [];
model.isLoading = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to load members');
}
}
Future<ApiResponse<RecipientGroup>> createGroup(
CreateGroupRequest req,
) async {
model.isLoading = true;
if (!_disposed) notifyListeners();
try {
final raw = await http.post('/public/recipient-groups', req.toJson());
final response = _unwrap(raw);
final group = RecipientGroup.fromJson(response as Map<String, dynamic>);
model.groups = [group, ...model.groups];
model.isLoading = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(group);
} catch (e) {
logger.e(e);
model.errorMessage = e.toString();
model.isLoading = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to create group');
}
}
Future<void> deleteGroup({
required String groupId,
required String userId,
}) async {
try {
await http.delete(
'/public/recipient-groups/delete/$groupId?userId=$userId',
);
model.groups.removeWhere((g) => g.id == groupId);
model.selectedGroupIds.remove(groupId);
if (!_disposed) notifyListeners();
} catch (e) {
logger.e(e);
model.errorMessage = e.toString();
if (!_disposed) notifyListeners();
}
}
void toggleSelectMode() {
model.selectMode = !model.selectMode;
if (!model.selectMode) {
model.selectedGroupIds.clear();
}
if (!_disposed) notifyListeners();
}
void toggleGroupSelection(String groupId) {
if (model.selectedGroupIds.contains(groupId)) {
model.selectedGroupIds.remove(groupId);
} else {
model.selectedGroupIds.add(groupId);
}
if (!_disposed) notifyListeners();
}
Future<void> removeMember({
required String groupId,
required String recipientId,
required String userId,
}) async {
try {
await http.delete(
'/public/recipient-groups/$groupId/members/$recipientId?userId=$userId',
);
model.members.removeWhere((m) => m.id == recipientId);
if (!_disposed) notifyListeners();
} catch (e) {
logger.e(e);
model.errorMessage = e.toString();
if (!_disposed) notifyListeners();
}
}
Future<void> deleteSelectedGroups({required String userId}) async {
final idsToDelete = List<String>.from(model.selectedGroupIds);
model.selectMode = false;
model.selectedGroupIds.clear();
if (!_disposed) notifyListeners();
for (final id in idsToDelete) {
await deleteGroup(groupId: id, userId: userId);
}
}
}

View File

@@ -0,0 +1,223 @@
import 'package:flutter/material.dart';
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:shared_preferences/shared_preferences.dart';
import '../recipient/recipients_controller.dart';
class GroupCreateScreen extends StatefulWidget {
const GroupCreateScreen({super.key});
@override
State<GroupCreateScreen> createState() => _GroupCreateScreenState();
}
class _GroupCreateScreenState extends State<GroupCreateScreen> {
late GroupController controller;
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
final _recipientsController = TextEditingController();
@override
void initState() {
super.initState();
controller = GroupController(context);
}
@override
void dispose() {
controller.dispose();
_nameController.dispose();
_descriptionController.dispose();
_recipientsController.dispose();
super.dispose();
}
List<Recipient> _parseRecipients(String raw) {
final lines = raw
.split('\n')
.map((l) => l.trim())
.where((l) => l.isNotEmpty)
.toList();
final List<Recipient> members = [];
for (final line in lines) {
final parts = line.split(',');
if (parts.length >= 2) {
final name = parts[0].trim();
final account = parts[1].trim();
if (name.isNotEmpty && account.isNotEmpty) {
members.add(
Recipient(
name: name,
account: account,
phoneNumber: account,
latestProviderLabel: '',
),
);
}
}
}
return members;
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
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.',
),
),
);
return;
}
final prefs = await SharedPreferences.getInstance();
final userId = prefs.getString('userId') ?? '';
final req = CreateGroupRequest(
name: _nameController.text.trim(),
description: _descriptionController.text.trim().isEmpty
? null
: _descriptionController.text.trim(),
userId: userId,
recipients: recipients,
);
final result = await controller.createGroup(req);
if (!mounted) return;
if (result.isSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('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.')),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('New Group'), centerTitle: true),
body: ListenableBuilder(
listenable: controller,
builder: (context, _) {
return Center(
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: [
TextFormField(
controller: _nameController,
textInputAction: TextInputAction.next,
decoration: InputDecoration(
labelText: 'Group Name',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (v) => (v == null || v.trim().isEmpty)
? 'Group name is required'
: null,
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
textInputAction: TextInputAction.next,
decoration: InputDecoration(
labelText: 'Description (optional)',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(height: 24),
Text(
'Recipients',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey.shade800,
),
),
const SizedBox(height: 4),
Text(
'Enter one recipient per line as: name,account',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 8),
TextFormField(
controller: _recipientsController,
maxLines: 8,
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
decoration: InputDecoration(
hintText:
'Alice,263773000001\nBob,263773000002\nCarol,263773000003',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
alignLabelWithHint: true,
),
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: controller.model.isLoading
? null
: _submit,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: controller.model.isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'Create Group',
style: TextStyle(fontSize: 16),
),
),
],
),
),
),
);
},
),
);
},
),
);
}
}

View File

@@ -0,0 +1,808 @@
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/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/widgets/status_chip_widget.dart';
class GroupDetailScreen extends StatefulWidget {
final RecipientGroup group;
const GroupDetailScreen({super.key, required this.group});
@override
State<GroupDetailScreen> createState() => _GroupDetailScreenState();
}
class _GroupDetailScreenState extends State<GroupDetailScreen> {
late GroupController groupController;
late BatchController batchController;
final _searchController = TextEditingController();
final _scrollController = ScrollController();
String? _statusFilter;
String? _currencyFilter;
static const _statusFilterItems = <DropdownMenuItem<String?>>[
DropdownMenuItem(value: null, child: Text('All')),
DropdownMenuItem(value: 'CREATED', child: Text('Created')),
DropdownMenuItem(value: 'PENDING', child: Text('Pending')),
DropdownMenuItem(value: 'CONFIRMED_ALL', child: Text('Confirmed')),
DropdownMenuItem(value: 'REQUESTED', child: Text('Requested')),
DropdownMenuItem(value: 'PROCESSING', child: Text('Processing')),
DropdownMenuItem(value: 'COMPLETED', child: Text('Completed')),
DropdownMenuItem(value: 'FAILED', child: Text('Failed')),
];
static const _currencyFilterItems = <DropdownMenuItem<String?>>[
DropdownMenuItem(value: null, child: Text('All')),
DropdownMenuItem(value: 'USD', child: Text('USD')),
DropdownMenuItem(value: 'ZWL', child: Text('ZWL')),
DropdownMenuItem(value: 'ZAR', child: Text('ZAR')),
];
@override
void initState() {
super.initState();
groupController = GroupController(context);
batchController = BatchController(context);
_loadData();
_scrollController.addListener(_onScroll);
_searchController.addListener(() => setState(() {}));
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
batchController.loadNextPage(widget.group.id ?? '');
}
}
Future<void> _loadData() async {
final groupId = widget.group.id ?? '';
await Future.wait([
batchController.listBatches(groupId),
groupController.listMembers(groupId),
]);
}
void _onSearchChanged(String value) {
batchController.searchBatches(
widget.group.id ?? '',
search: value.isNotEmpty ? value : null,
status: _statusFilter,
currency: _currencyFilter,
);
}
void _showMembersSheet() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
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(
children: [
const Text(
'Members',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
const SizedBox(height: 4),
Text(
'${groupController.model.members.length} member(s)',
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
),
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,
);
},
);
},
),
),
],
),
);
},
),
);
}
void _onDeleteSelected() async {
final userId = widget.group.userId ?? '';
final count = batchController.model.selectedBatchIds.length;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete batches'),
content: Text('Are you sure you want to delete $count batch(es)?'),
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('Delete'),
),
],
),
);
if (confirmed == true) {
await batchController.deleteSelectedBatches(
groupId: widget.group.id ?? '',
userId: userId,
);
}
}
void _showFilterSheet() {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) {
return StatefulBuilder(
builder: (context, setSheetState) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'Filter Batches',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
TextButton(
onPressed: () {
setSheetState(() {
_statusFilter = null;
_currencyFilter = null;
});
},
child: const Text('Clear All'),
),
],
),
const SizedBox(height: 16),
DropdownButtonFormField<String?>(
initialValue: _statusFilter,
decoration: InputDecoration(
labelText: 'Status',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
items: _statusFilterItems,
onChanged: (v) {
setSheetState(() => _statusFilter = v);
},
),
const SizedBox(height: 16),
DropdownButtonFormField<String?>(
initialValue: _currencyFilter,
decoration: InputDecoration(
labelText: 'Currency',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
items: _currencyFilterItems,
onChanged: (v) {
setSheetState(() => _currencyFilter = v);
},
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => _applyFilters(ctx),
child: const Text('Apply Filters'),
),
),
const SizedBox(height: 8),
],
),
);
},
);
},
);
}
void _applyFilters(BuildContext ctx) {
Navigator.of(ctx).pop();
batchController.searchBatches(
widget.group.id ?? '',
search: _searchController.text.isNotEmpty ? _searchController.text : null,
status: _statusFilter,
currency: _currencyFilter,
);
}
@override
void dispose() {
_searchController.dispose();
_scrollController.dispose();
groupController.dispose();
batchController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.group.name),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.people_outline),
tooltip: 'View members',
onPressed: _showMembersSheet,
),
],
),
body: ListenableBuilder(
listenable: batchController,
builder: (context, _) {
return Column(
children: [
Expanded(
child: CustomScrollView(
controller: _scrollController,
slivers: [
SliverToBoxAdapter(
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
final width =
constraints.maxWidth > ResponsivePolicy.md
? ResponsivePolicy.md.toDouble()
: constraints.maxWidth;
return SizedBox(
width: width,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSearchRow(),
const SizedBox(height: 16),
_buildContent(),
],
),
),
);
},
),
),
),
],
),
),
if (batchController.model.selectMode) _buildSelectionBottomBar(),
],
);
},
),
);
}
Widget _buildSearchRow() {
return Row(
children: [
Expanded(
child: TextFormField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search batches by description...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_onSearchChanged('');
},
)
: null,
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
),
),
),
onChanged: _onSearchChanged,
),
),
const SizedBox(width: 8),
_buildIconButton(
icon: Icons.filter_list,
tooltip: 'Filter batches',
isActive: _statusFilter != null || _currencyFilter != null,
onPressed: _showFilterSheet,
),
const SizedBox(width: 8),
_buildIconButton(
icon: batchController.model.selectMode
? Icons.checklist
: Icons.checklist_outlined,
tooltip: 'Select batches',
isActive: batchController.model.selectMode,
onPressed: () => batchController.toggleSelectMode(),
),
const SizedBox(width: 8),
_buildIconButton(
icon: Icons.add,
tooltip: 'Create batch',
isActive: false,
onPressed: () async {
final result = await context.push(
'/groups/${widget.group.id}/batches/create',
extra: widget.group,
);
if (result == true) {
_loadData();
}
},
),
],
);
}
Widget _buildIconButton({
required IconData icon,
required String tooltip,
required bool isActive,
required VoidCallback onPressed,
}) {
return Container(
decoration: BoxDecoration(
color: isActive
? Theme.of(context).colorScheme.primary
: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
icon: Icon(icon, color: isActive ? Colors.white : Colors.grey.shade700),
tooltip: tooltip,
onPressed: onPressed,
),
);
}
Widget _buildContent() {
if (batchController.model.isLoading) {
return const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 60),
child: CircularProgressIndicator(),
),
);
}
if (batchController.model.batches.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Column(
children: [
Icon(
Icons.receipt_long_outlined,
size: 64,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
const Text(
'No batches yet.',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Text(
'Tap + to create a new batch payment.',
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
),
],
),
),
);
}
return Column(
children: [
if (batchController.model.selectMode)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Text(
'${batchController.model.selectedBatchIds.length} selected',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.grey.shade600,
),
),
],
),
),
if (_statusFilter != null || _currencyFilter != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
if (_statusFilter != null)
_activeFilterChip('Status: $_statusFilter', () {
setState(() => _statusFilter = null);
_onSearchChanged(_searchController.text);
}),
if (_currencyFilter != null) const SizedBox(width: 6),
if (_currencyFilter != null)
_activeFilterChip('Currency: $_currencyFilter', () {
setState(() => _currencyFilter = null);
_onSearchChanged(_searchController.text);
}),
],
),
),
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: batchController.model.batches.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
return _buildBatchCard(
context,
batchController.model.batches[index],
widget.group,
);
},
),
if (batchController.model.isLoadingMore)
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
],
);
}
Widget _activeFilterChip(String label, VoidCallback onRemove) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withAlpha(20),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withAlpha(60),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 4),
InkWell(
onTap: onRemove,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(
Icons.close,
size: 14,
color: Theme.of(context).colorScheme.primary,
),
),
),
],
),
);
}
Widget _buildBatchCard(
BuildContext context,
GroupBatch batch,
RecipientGroup group,
) {
final isSelected = batchController.model.selectedBatchIds.contains(
batch.id,
);
final inSelectMode = batchController.model.selectMode;
final status = batch.status ?? 'UNKNOWN';
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: inSelectMode && isSelected
? Theme.of(context).colorScheme.primary
: Colors.black12.withAlpha(30),
width: inSelectMode && isSelected ? 2 : 1,
),
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: inSelectMode
? () => batchController.toggleBatchSelection(batch.id!)
: () => context.push('/groups/${group.id}/batches/${batch.id}'),
child: Padding(
padding: const EdgeInsets.all(10),
child: Row(
children: [
if (inSelectMode)
Padding(
padding: const EdgeInsets.only(right: 8),
child: Icon(
isSelected
? Icons.check_box
: Icons.check_box_outline_blank,
size: 20,
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.grey.shade400,
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (batch.description != null &&
batch.description!.isNotEmpty)
Flexible(
child: Text(
batch.description!,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 6),
StatusChip(status: status),
],
),
const SizedBox(height: 4),
Text(
'${batch.currency ?? 'USD'} ${batch.value?.toStringAsFixed(2) ?? ''}',
style: TextStyle(
fontSize: 13,
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,
),
const SizedBox(width: 3),
_countPill(
'${batch.failedCount ?? 0} failed',
Colors.red,
),
],
),
),
],
),
),
if (!inSelectMode)
Icon(Icons.chevron_right, color: Colors.grey.shade400),
],
),
),
),
);
}
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 _buildSelectionBottomBar() {
final selectedCount = batchController.model.selectedBatchIds.length;
return Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Text(
'$selectedCount selected',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const Spacer(),
TextButton.icon(
onPressed: selectedCount > 0 ? _onDeleteSelected : null,
icon: const Icon(Icons.delete_outline, size: 20),
label: const Text('Delete'),
style: TextButton.styleFrom(foregroundColor: Colors.red),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,426 @@
import 'package:flutter/material.dart';
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:shared_preferences/shared_preferences.dart';
class GroupsScreen extends StatefulWidget {
const GroupsScreen({super.key});
@override
State<GroupsScreen> createState() => _GroupsScreenState();
}
class _GroupsScreenState extends State<GroupsScreen> {
late GroupController controller;
late SharedPreferences prefs;
final _searchController = TextEditingController();
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
controller = GroupController(context);
_loadData();
_scrollController.addListener(_onScroll);
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
controller.loadNextPage(userId: prefs.getString('userId') ?? '');
}
}
Future<void> _loadData() async {
prefs = await SharedPreferences.getInstance();
final userId = prefs.getString('userId') ?? '';
await controller.listGroups(userId: userId);
}
void _onSearchChanged(String value) {
final userId = prefs.getString('userId') ?? '';
controller.searchGroups(
userId: userId,
name: value.isNotEmpty ? value : null,
);
}
void _onDeleteSelected() async {
final userId = prefs.getString('userId') ?? '';
final count = controller.model.selectedGroupIds.length;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete groups'),
content: Text('Are you sure you want to delete $count group(s)?'),
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('Delete'),
),
],
),
);
if (confirmed == true) {
await controller.deleteSelectedGroups(userId: userId);
}
}
@override
void dispose() {
_searchController.dispose();
_scrollController.dispose();
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(
'Groups',
style: TextStyle(fontSize: 20, color: Colors.black),
),
centerTitle: true,
),
body: ListenableBuilder(
listenable: controller,
builder: (context, _) {
return Column(
children: [
Expanded(
child: SingleChildScrollView(
controller: _scrollController,
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth > ResponsivePolicy.md
? ResponsivePolicy.md.toDouble()
: constraints.maxWidth;
return SizedBox(
width: width,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSearchRow(),
const SizedBox(height: 16),
_buildContent(),
],
),
),
);
},
),
),
),
),
if (controller.model.selectMode) _buildSelectionBottomBar(),
],
);
},
),
);
}
Widget _buildSearchRow() {
return Row(
children: [
Expanded(
child: TextFormField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search groups by name...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_onSearchChanged('');
},
)
: null,
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
),
),
),
onChanged: _onSearchChanged,
),
),
const SizedBox(width: 8),
_buildIconButton(
icon: controller.model.selectMode
? Icons.checklist
: Icons.checklist_outlined,
tooltip: 'Select groups',
isActive: controller.model.selectMode,
onPressed: () => controller.toggleSelectMode(),
),
const SizedBox(width: 8),
_buildIconButton(
icon: Icons.add,
tooltip: 'Create new group',
isActive: false,
onPressed: _createGroup,
),
],
);
}
_createGroup() async {
final response = await context.push('/groups/create');
if (response == true) {
_loadData();
}
}
Widget _buildIconButton({
required IconData icon,
required String tooltip,
required bool isActive,
required VoidCallback onPressed,
}) {
return Container(
decoration: BoxDecoration(
color: isActive
? Theme.of(context).colorScheme.primary
: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
icon: Icon(icon, color: isActive ? Colors.white : Colors.grey.shade700),
tooltip: tooltip,
onPressed: onPressed,
),
);
}
Widget _buildContent() {
if (controller.model.isLoading) {
return const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 60),
child: CircularProgressIndicator(),
),
);
}
if (controller.model.groups.isEmpty) {
return Center(child: _buildEmptyState());
}
return Column(
children: [
if (controller.model.selectMode)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Text(
'${controller.model.selectedGroupIds.length} selected',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.grey.shade600,
),
),
],
),
),
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: controller.model.groups.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
return _buildGroupCard(controller.model.groups[index]);
},
),
if (controller.model.isLoadingMore)
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
],
);
}
Widget _buildEmptyState() {
return Column(
children: [
const SizedBox(height: 40),
Icon(Icons.group_outlined, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
const Text(
'No groups yet.',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Text(
'Create a group to start sending batch payments.',
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
textAlign: TextAlign.center,
),
],
);
}
Widget _buildGroupCard(RecipientGroup group) {
final isSelected = controller.model.selectedGroupIds.contains(group.id);
final inSelectMode = controller.model.selectMode;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: inSelectMode && isSelected
? Theme.of(context).colorScheme.primary
: Colors.black12.withAlpha(30),
width: inSelectMode && isSelected ? 2 : 1,
),
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: inSelectMode
? () => controller.toggleGroupSelection(group.id!)
: () => context.push('/groups/${group.id}', extra: group),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
if (inSelectMode)
Padding(
padding: const EdgeInsets.only(right: 12),
child: Icon(
isSelected
? Icons.check_box
: Icons.check_box_outline_blank,
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.grey.shade400,
),
),
CircleAvatar(
backgroundColor: Theme.of(
context,
).colorScheme.primary.withAlpha(30),
child: Text(
_getGroupAbbreviation(group.name),
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
group.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
if (group.description != null &&
group.description!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
group.description!,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
if (!inSelectMode)
Icon(Icons.chevron_right, color: Colors.grey.shade400),
],
),
),
),
);
}
String _getGroupAbbreviation(String name) {
if (name.isEmpty) return '?';
final words = name.trim().split(RegExp(r'\s+'));
if (words.length >= 2) {
return '${words.first[0]}${words.last[0]}'.toUpperCase();
}
return name.length >= 2
? name.substring(0, 2).toUpperCase()
: name[0].toUpperCase();
}
Widget _buildSelectionBottomBar() {
final selectedCount = controller.model.selectedGroupIds.length;
return Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Text(
'$selectedCount selected',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const Spacer(),
TextButton.icon(
onPressed: selectedCount > 0 ? _onDeleteSelected : null,
icon: const Icon(Icons.delete_outline, size: 20),
label: const Text('Delete'),
style: TextButton.styleFrom(foregroundColor: Colors.red),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,143 @@
import 'package:json_annotation/json_annotation.dart';
part 'group_batch_model.g.dart';
@JsonSerializable(explicitToJson: true)
class GroupBatch {
final String? id;
final String? recipientGroupId;
final String? userId;
final String? currency;
final String? description;
final double? value;
final String? status;
final String? paymentProcessorLabel;
final String? paymentProcessorName;
final String? paymentProcessorImage;
final String? transactionTemplate;
final String? createdAt;
final int? count;
final int? successfulCount;
final int? failedCount;
const GroupBatch({
this.id,
this.recipientGroupId,
this.userId,
this.currency,
this.description,
this.value,
this.status,
this.paymentProcessorLabel,
this.paymentProcessorName,
this.paymentProcessorImage,
this.transactionTemplate,
this.createdAt,
this.count,
this.successfulCount,
this.failedCount,
});
factory GroupBatch.fromJson(Map<String, dynamic> json) =>
_$GroupBatchFromJson(json);
Map<String, dynamic> toJson() => _$GroupBatchToJson(this);
}
@JsonSerializable(explicitToJson: true)
class GroupBatchItem {
final String? id;
final String? groupBatchId;
final String? recipientName;
final String? recipientPhone;
final double? amount;
final String? status;
final String? transactionId;
final String? errorMessage;
final String? createdAt;
const GroupBatchItem({
this.id,
this.groupBatchId,
this.recipientName,
this.recipientPhone,
this.amount,
this.status,
this.transactionId,
this.errorMessage,
this.createdAt,
});
factory GroupBatchItem.fromJson(Map<String, dynamic> json) =>
_$GroupBatchItemFromJson(json);
Map<String, dynamic> toJson() => _$GroupBatchItemToJson(this);
}
@JsonSerializable(explicitToJson: true)
class CreateBatchRequest {
final String recipientGroupId;
final String userId;
final String currency;
final String description;
final double amount;
final String? paymentProcessorLabel;
final String? paymentProcessorName;
final String? paymentProcessorImage;
final Map<String, dynamic> transactionTemplate;
const CreateBatchRequest({
required this.recipientGroupId,
required this.userId,
required this.currency,
required this.description,
required this.amount,
this.paymentProcessorLabel,
this.paymentProcessorName,
this.paymentProcessorImage,
required this.transactionTemplate,
});
factory CreateBatchRequest.fromJson(Map<String, dynamic> json) =>
_$CreateBatchRequestFromJson(json);
Map<String, dynamic> toJson() => _$CreateBatchRequestToJson(this);
}
@JsonSerializable()
class BatchActionRequest {
final String userId;
final String idempotencyKey;
const BatchActionRequest({
required this.userId,
required this.idempotencyKey,
});
factory BatchActionRequest.fromJson(Map<String, dynamic> json) =>
_$BatchActionRequestFromJson(json);
Map<String, dynamic> toJson() => _$BatchActionRequestToJson(this);
}
@JsonSerializable(explicitToJson: true)
class GroupBatchUpdateRequest {
String? id;
String? userId;
String? paymentProcessorLabel;
String? paymentProcessorName;
String? paymentProcessorImage;
GroupBatchUpdateRequest({
this.id,
this.userId,
this.paymentProcessorLabel,
this.paymentProcessorName,
this.paymentProcessorImage,
});
factory GroupBatchUpdateRequest.fromJson(Map<String, dynamic> json) =>
_$GroupBatchUpdateRequestFromJson(json);
Map<String, dynamic> toJson() => _$GroupBatchUpdateRequestToJson(this);
}

View File

@@ -0,0 +1,128 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'group_batch_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
GroupBatch _$GroupBatchFromJson(Map<String, dynamic> json) => GroupBatch(
id: json['id'] as String?,
recipientGroupId: json['recipientGroupId'] as String?,
userId: json['userId'] as String?,
currency: json['currency'] as String?,
description: json['description'] as String?,
value: (json['value'] as num?)?.toDouble(),
status: json['status'] as String?,
paymentProcessorLabel: json['paymentProcessorLabel'] as String?,
paymentProcessorName: json['paymentProcessorName'] as String?,
paymentProcessorImage: json['paymentProcessorImage'] as String?,
transactionTemplate: json['transactionTemplate'] as String?,
createdAt: json['createdAt'] as String?,
count: (json['count'] as num?)?.toInt(),
successfulCount: (json['successfulCount'] as num?)?.toInt(),
failedCount: (json['failedCount'] as num?)?.toInt(),
);
Map<String, dynamic> _$GroupBatchToJson(GroupBatch instance) =>
<String, dynamic>{
'id': instance.id,
'recipientGroupId': instance.recipientGroupId,
'userId': instance.userId,
'currency': instance.currency,
'description': instance.description,
'value': instance.value,
'status': instance.status,
'paymentProcessorLabel': instance.paymentProcessorLabel,
'paymentProcessorName': instance.paymentProcessorName,
'paymentProcessorImage': instance.paymentProcessorImage,
'transactionTemplate': instance.transactionTemplate,
'createdAt': instance.createdAt,
'count': instance.count,
'successfulCount': instance.successfulCount,
'failedCount': instance.failedCount,
};
GroupBatchItem _$GroupBatchItemFromJson(Map<String, dynamic> json) =>
GroupBatchItem(
id: json['id'] as String?,
groupBatchId: json['groupBatchId'] as String?,
recipientName: json['recipientName'] as String?,
recipientPhone: json['recipientPhone'] as String?,
amount: (json['amount'] as num?)?.toDouble(),
status: json['status'] as String?,
transactionId: json['transactionId'] as String?,
errorMessage: json['errorMessage'] as String?,
createdAt: json['createdAt'] as String?,
);
Map<String, dynamic> _$GroupBatchItemToJson(GroupBatchItem instance) =>
<String, dynamic>{
'id': instance.id,
'groupBatchId': instance.groupBatchId,
'recipientName': instance.recipientName,
'recipientPhone': instance.recipientPhone,
'amount': instance.amount,
'status': instance.status,
'transactionId': instance.transactionId,
'errorMessage': instance.errorMessage,
'createdAt': instance.createdAt,
};
CreateBatchRequest _$CreateBatchRequestFromJson(Map<String, dynamic> json) =>
CreateBatchRequest(
recipientGroupId: json['recipientGroupId'] as String,
userId: json['userId'] as String,
currency: json['currency'] as String,
description: json['description'] as String,
amount: (json['amount'] as num).toDouble(),
paymentProcessorLabel: json['paymentProcessorLabel'] as String?,
paymentProcessorName: json['paymentProcessorName'] as String?,
paymentProcessorImage: json['paymentProcessorImage'] as String?,
transactionTemplate: json['transactionTemplate'] as Map<String, dynamic>,
);
Map<String, dynamic> _$CreateBatchRequestToJson(CreateBatchRequest instance) =>
<String, dynamic>{
'recipientGroupId': instance.recipientGroupId,
'userId': instance.userId,
'currency': instance.currency,
'description': instance.description,
'amount': instance.amount,
'paymentProcessorLabel': instance.paymentProcessorLabel,
'paymentProcessorName': instance.paymentProcessorName,
'paymentProcessorImage': instance.paymentProcessorImage,
'transactionTemplate': instance.transactionTemplate,
};
BatchActionRequest _$BatchActionRequestFromJson(Map<String, dynamic> json) =>
BatchActionRequest(
userId: json['userId'] as String,
idempotencyKey: json['idempotencyKey'] as String,
);
Map<String, dynamic> _$BatchActionRequestToJson(BatchActionRequest instance) =>
<String, dynamic>{
'userId': instance.userId,
'idempotencyKey': instance.idempotencyKey,
};
GroupBatchUpdateRequest _$GroupBatchUpdateRequestFromJson(
Map<String, dynamic> json,
) => GroupBatchUpdateRequest(
id: json['id'] as String?,
userId: json['userId'] as String?,
paymentProcessorLabel: json['paymentProcessorLabel'] as String?,
paymentProcessorName: json['paymentProcessorName'] as String?,
paymentProcessorImage: json['paymentProcessorImage'] as String?,
);
Map<String, dynamic> _$GroupBatchUpdateRequestToJson(
GroupBatchUpdateRequest instance,
) => <String, dynamic>{
'id': instance.id,
'userId': instance.userId,
'paymentProcessorLabel': instance.paymentProcessorLabel,
'paymentProcessorName': instance.paymentProcessorName,
'paymentProcessorImage': instance.paymentProcessorImage,
};

View File

@@ -0,0 +1,72 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:qpay/screens/recipient/recipients_controller.dart';
part 'recipient_group_model.g.dart';
@JsonSerializable(explicitToJson: true)
class RecipientGroup {
final String? id;
final String name;
final String? description;
final String? userId;
final String? createdAt;
const RecipientGroup({
this.id,
required this.name,
this.description,
this.userId,
this.createdAt,
});
factory RecipientGroup.fromJson(Map<String, dynamic> json) =>
_$RecipientGroupFromJson(json);
Map<String, dynamic> toJson() => _$RecipientGroupToJson(this);
}
@JsonSerializable(explicitToJson: true)
class RecipientGroupMember {
final String? id;
final String recipientGroupId;
final String? recipientUid;
final String? userId;
final String? account;
final String? createdAt;
final Recipient recipient;
const RecipientGroupMember({
this.id,
required this.recipientGroupId,
this.recipientUid,
this.userId,
this.account,
this.createdAt,
required this.recipient,
});
factory RecipientGroupMember.fromJson(Map<String, dynamic> json) =>
_$RecipientGroupMemberFromJson(json);
Map<String, dynamic> toJson() => _$RecipientGroupMemberToJson(this);
}
@JsonSerializable(explicitToJson: true)
class CreateGroupRequest {
final String name;
final String? description;
final String userId;
final List<Recipient> recipients;
const CreateGroupRequest({
required this.name,
this.description,
required this.userId,
required this.recipients,
});
factory CreateGroupRequest.fromJson(Map<String, dynamic> json) =>
_$CreateGroupRequestFromJson(json);
Map<String, dynamic> toJson() => _$CreateGroupRequestToJson(this);
}

View File

@@ -0,0 +1,67 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'recipient_group_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
RecipientGroup _$RecipientGroupFromJson(Map<String, dynamic> json) =>
RecipientGroup(
id: json['id'] as String?,
name: json['name'] as String,
description: json['description'] as String?,
userId: json['userId'] as String?,
createdAt: json['createdAt'] as String?,
);
Map<String, dynamic> _$RecipientGroupToJson(RecipientGroup instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'description': instance.description,
'userId': instance.userId,
'createdAt': instance.createdAt,
};
RecipientGroupMember _$RecipientGroupMemberFromJson(
Map<String, dynamic> json,
) => RecipientGroupMember(
id: json['id'] as String?,
recipientGroupId: json['recipientGroupId'] as String,
recipientUid: json['recipientUid'] as String?,
userId: json['userId'] as String?,
account: json['account'] as String?,
createdAt: json['createdAt'] as String?,
recipient: Recipient.fromJson(json['recipient'] as Map<String, dynamic>),
);
Map<String, dynamic> _$RecipientGroupMemberToJson(
RecipientGroupMember instance,
) => <String, dynamic>{
'id': instance.id,
'recipientGroupId': instance.recipientGroupId,
'recipientUid': instance.recipientUid,
'userId': instance.userId,
'account': instance.account,
'createdAt': instance.createdAt,
'recipient': instance.recipient.toJson(),
};
CreateGroupRequest _$CreateGroupRequestFromJson(Map<String, dynamic> json) =>
CreateGroupRequest(
name: json['name'] as String,
description: json['description'] as String?,
userId: json['userId'] as String,
recipients: (json['recipients'] as List<dynamic>)
.map((e) => Recipient.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$CreateGroupRequestToJson(CreateGroupRequest instance) =>
<String, dynamic>{
'name': instance.name,
'description': instance.description,
'userId': instance.userId,
'recipients': instance.recipients.map((e) => e.toJson()).toList(),
};

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/onboarding/login/login_controller.dart';
class CompleteScreen extends StatefulWidget {
const CompleteScreen({super.key});

View File

@@ -259,7 +259,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
),
),
),
const SizedBox(height: 24),
const SizedBox(height: 16),
// Animated info card section
FadeTransition(
@@ -272,7 +272,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
),
),
),
const SizedBox(height: 20),
const SizedBox(height: 14),
// Animated content section
FadeTransition(
@@ -386,7 +386,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
],
stops: const [0.0, 0.5, 1.0],
),
borderRadius: BorderRadius.circular(24),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: primaryColor.withValues(alpha: 0.12),
width: 1,
@@ -399,13 +399,13 @@ class _ReceiptScreenState extends State<ReceiptScreen>
),
],
),
padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 24),
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20),
child: Column(
children: [
// Status badge
if (status != null) ...[
_buildStatusBadge(status, theme),
const SizedBox(height: 16),
const SizedBox(height: 12),
],
// Amount row
@@ -433,14 +433,14 @@ class _ReceiptScreenState extends State<ReceiptScreen>
),
],
),
const SizedBox(height: 12),
const SizedBox(height: 8),
// Bill name (if available)
if (billName.isNotEmpty)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 6,
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: primaryColor.withValues(alpha: 0.08),
@@ -455,7 +455,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
),
),
),
const SizedBox(height: 6),
const SizedBox(height: 4),
// Date
if (createdAt != null)
@@ -467,11 +467,11 @@ class _ReceiptScreenState extends State<ReceiptScreen>
color: isDark ? Colors.white54 : Colors.grey.shade500,
),
),
const SizedBox(height: 24),
const SizedBox(height: 16),
// Divider with dots pattern
_buildDottedDivider(isDark),
const SizedBox(height: 20),
const SizedBox(height: 14),
// Action buttons row
_buildActionButtons(theme),
@@ -487,7 +487,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
Widget _buildDottedDivider(bool isDark) {
return Row(
children: List.generate(
60,
40,
(index) => Expanded(
child: Container(
height: 1.5,
@@ -508,8 +508,8 @@ class _ReceiptScreenState extends State<ReceiptScreen>
final paymentStatus = _transaction?.paymentStatus;
return Wrap(
spacing: 12,
runSpacing: 8,
spacing: 10,
runSpacing: 6,
alignment: WrapAlignment.center,
children: [
_modernActionButton(
@@ -926,7 +926,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
],
),
child: Padding(
padding: const EdgeInsets.all(20),
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -954,7 +954,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
),
],
),
const SizedBox(height: 16),
const SizedBox(height: 12),
// Divider
Container(
@@ -963,12 +963,12 @@ class _ReceiptScreenState extends State<ReceiptScreen>
? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade200,
),
const SizedBox(height: 16),
const SizedBox(height: 12),
// Detail items
...items.map(
(item) => Padding(
padding: const EdgeInsets.only(bottom: 14),
padding: const EdgeInsets.only(bottom: 10),
child: _buildModernDetailRow(
theme: theme,
isDark: isDark,
@@ -980,7 +980,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
// Total amount divider & total
if (totalAmount != null) ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(vertical: 2),
child: Container(
height: 1,
decoration: BoxDecoration(
@@ -993,7 +993,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
),
),
),
const SizedBox(height: 12),
const SizedBox(height: 8),
Row(
children: [
Container(

View File

@@ -20,14 +20,14 @@ class RecipientModel {
@freezed
abstract class Recipient with _$Recipient {
const factory Recipient({
String? uid,
String? name,
String? email,
String? phoneNumber,
String? address,
String? account,
String? initials,
String? latestProviderLabel,
@JsonKey(includeIfNull: false) String? uid,
@JsonKey(includeIfNull: false) String? name,
@JsonKey(includeIfNull: false) String? email,
@JsonKey(includeIfNull: false) String? phoneNumber,
@JsonKey(includeIfNull: false) String? address,
@JsonKey(includeIfNull: false) String? account,
@JsonKey(includeIfNull: false) String? initials,
@JsonKey(includeIfNull: false) String? latestProviderLabel,
}) = _Recipient;
factory Recipient.fromJson(Map<String, dynamic> json) =>
@@ -40,14 +40,15 @@ class RecipientsController extends ChangeNotifier {
BuildContext context;
RecipientsController(this.context) {
model.selectedProvider =
Provider.of<TransactionController>(
model.selectedProvider = Provider.of<TransactionController>(
context,
listen: false,
).model.selectedProvider;
}
Future<ApiResponse<List<Recipient>>> getRecipients([Map<String, String>? params]) async {
Future<ApiResponse<List<Recipient>>> getRecipients([
Map<String, String>? params,
]) async {
model.errorMessage = null;
model.isLoading = true;
model.recipients = getDummyRecipients();

View File

@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc
mixin _$Recipient {
String? get uid; String? get name; String? get email; String? get phoneNumber; String? get address; String? get account; String? get initials; String? get latestProviderLabel;
@JsonKey(includeIfNull: false) String? get uid;@JsonKey(includeIfNull: false) String? get name;@JsonKey(includeIfNull: false) String? get email;@JsonKey(includeIfNull: false) String? get phoneNumber;@JsonKey(includeIfNull: false) String? get address;@JsonKey(includeIfNull: false) String? get account;@JsonKey(includeIfNull: false) String? get initials;@JsonKey(includeIfNull: false) String? get latestProviderLabel;
/// Create a copy of Recipient
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -48,7 +48,7 @@ abstract mixin class $RecipientCopyWith<$Res> {
factory $RecipientCopyWith(Recipient value, $Res Function(Recipient) _then) = _$RecipientCopyWithImpl;
@useResult
$Res call({
String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel
@JsonKey(includeIfNull: false) String? uid,@JsonKey(includeIfNull: false) String? name,@JsonKey(includeIfNull: false) String? email,@JsonKey(includeIfNull: false) String? phoneNumber,@JsonKey(includeIfNull: false) String? address,@JsonKey(includeIfNull: false) String? account,@JsonKey(includeIfNull: false) String? initials,@JsonKey(includeIfNull: false) String? latestProviderLabel
});
@@ -160,7 +160,7 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function(@JsonKey(includeIfNull: false) String? uid, @JsonKey(includeIfNull: false) String? name, @JsonKey(includeIfNull: false) String? email, @JsonKey(includeIfNull: false) String? phoneNumber, @JsonKey(includeIfNull: false) String? address, @JsonKey(includeIfNull: false) String? account, @JsonKey(includeIfNull: false) String? initials, @JsonKey(includeIfNull: false) String? latestProviderLabel)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _Recipient() when $default != null:
return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address,_that.account,_that.initials,_that.latestProviderLabel);case _:
@@ -181,7 +181,7 @@ return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function(@JsonKey(includeIfNull: false) String? uid, @JsonKey(includeIfNull: false) String? name, @JsonKey(includeIfNull: false) String? email, @JsonKey(includeIfNull: false) String? phoneNumber, @JsonKey(includeIfNull: false) String? address, @JsonKey(includeIfNull: false) String? account, @JsonKey(includeIfNull: false) String? initials, @JsonKey(includeIfNull: false) String? latestProviderLabel) $default,) {final _that = this;
switch (_that) {
case _Recipient():
return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address,_that.account,_that.initials,_that.latestProviderLabel);case _:
@@ -201,7 +201,7 @@ return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function(@JsonKey(includeIfNull: false) String? uid, @JsonKey(includeIfNull: false) String? name, @JsonKey(includeIfNull: false) String? email, @JsonKey(includeIfNull: false) String? phoneNumber, @JsonKey(includeIfNull: false) String? address, @JsonKey(includeIfNull: false) String? account, @JsonKey(includeIfNull: false) String? initials, @JsonKey(includeIfNull: false) String? latestProviderLabel)? $default,) {final _that = this;
switch (_that) {
case _Recipient() when $default != null:
return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address,_that.account,_that.initials,_that.latestProviderLabel);case _:
@@ -216,17 +216,17 @@ return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address
@JsonSerializable()
class _Recipient implements Recipient {
const _Recipient({this.uid, this.name, this.email, this.phoneNumber, this.address, this.account, this.initials, this.latestProviderLabel});
const _Recipient({@JsonKey(includeIfNull: false) this.uid, @JsonKey(includeIfNull: false) this.name, @JsonKey(includeIfNull: false) this.email, @JsonKey(includeIfNull: false) this.phoneNumber, @JsonKey(includeIfNull: false) this.address, @JsonKey(includeIfNull: false) this.account, @JsonKey(includeIfNull: false) this.initials, @JsonKey(includeIfNull: false) this.latestProviderLabel});
factory _Recipient.fromJson(Map<String, dynamic> json) => _$RecipientFromJson(json);
@override final String? uid;
@override final String? name;
@override final String? email;
@override final String? phoneNumber;
@override final String? address;
@override final String? account;
@override final String? initials;
@override final String? latestProviderLabel;
@override@JsonKey(includeIfNull: false) final String? uid;
@override@JsonKey(includeIfNull: false) final String? name;
@override@JsonKey(includeIfNull: false) final String? email;
@override@JsonKey(includeIfNull: false) final String? phoneNumber;
@override@JsonKey(includeIfNull: false) final String? address;
@override@JsonKey(includeIfNull: false) final String? account;
@override@JsonKey(includeIfNull: false) final String? initials;
@override@JsonKey(includeIfNull: false) final String? latestProviderLabel;
/// Create a copy of Recipient
/// with the given fields replaced by the non-null parameter values.
@@ -261,7 +261,7 @@ abstract mixin class _$RecipientCopyWith<$Res> implements $RecipientCopyWith<$Re
factory _$RecipientCopyWith(_Recipient value, $Res Function(_Recipient) _then) = __$RecipientCopyWithImpl;
@override @useResult
$Res call({
String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel
@JsonKey(includeIfNull: false) String? uid,@JsonKey(includeIfNull: false) String? name,@JsonKey(includeIfNull: false) String? email,@JsonKey(includeIfNull: false) String? phoneNumber,@JsonKey(includeIfNull: false) String? address,@JsonKey(includeIfNull: false) String? account,@JsonKey(includeIfNull: false) String? initials,@JsonKey(includeIfNull: false) String? latestProviderLabel
});

View File

@@ -19,12 +19,12 @@ _Recipient _$RecipientFromJson(Map<String, dynamic> json) => _Recipient(
Map<String, dynamic> _$RecipientToJson(_Recipient instance) =>
<String, dynamic>{
'uid': instance.uid,
'name': instance.name,
'email': instance.email,
'phoneNumber': instance.phoneNumber,
'address': instance.address,
'account': instance.account,
'initials': instance.initials,
'latestProviderLabel': instance.latestProviderLabel,
'uid': ?instance.uid,
'name': ?instance.name,
'email': ?instance.email,
'phoneNumber': ?instance.phoneNumber,
'address': ?instance.address,
'account': ?instance.account,
'initials': ?instance.initials,
'latestProviderLabel': ?instance.latestProviderLabel,
};

View File

@@ -0,0 +1,44 @@
import 'package:flutter/material.dart';
class StatusChip extends StatelessWidget {
final String status;
const StatusChip({super.key, required this.status});
Color _color() {
switch (status.toUpperCase()) {
case 'SUCCESS':
case 'COMPLETE':
return Colors.green;
case 'FAILED':
return Colors.red;
case 'PROCESSING':
return Colors.orange;
case 'CONFIRMED':
return Colors.blue;
default:
return Colors.grey;
}
}
@override
Widget build(BuildContext context) {
final color = _color();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: color.withAlpha(25),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color.withAlpha(80)),
),
child: Text(
status,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color,
),
),
);
}
}