progress on batches
This commit is contained in:
4225
QPay.postman_collection.json
Normal file
4225
QPay.postman_collection.json
Normal file
File diff suppressed because it is too large
Load Diff
83
group-batches.md
Normal file
83
group-batches.md
Normal 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
|
||||||
@@ -43,4 +43,24 @@ class Http {
|
|||||||
logger.i(response.data.toString());
|
logger.i(response.data.toString());
|
||||||
return response.data;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ import 'package:qpay/screens/receipt/receipt_screen.dart';
|
|||||||
import 'package:qpay/screens/recipient/recipients_screen.dart';
|
import 'package:qpay/screens/recipient/recipients_screen.dart';
|
||||||
import 'package:qpay/screens/transaction_controller.dart';
|
import 'package:qpay/screens/transaction_controller.dart';
|
||||||
import 'package:qpay/screens/profile_screen.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:qpay/theme/app_theme.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
@@ -49,6 +57,7 @@ String resolveEnvFile(String env) {
|
|||||||
/// navigation to the originally intended URL.
|
/// navigation to the originally intended URL.
|
||||||
class SplashState extends ChangeNotifier {
|
class SplashState extends ChangeNotifier {
|
||||||
bool _complete = false;
|
bool _complete = false;
|
||||||
|
|
||||||
/// The route the user originally intended to visit before being
|
/// The route the user originally intended to visit before being
|
||||||
/// redirected to /splash. Set by the redirect callback on first
|
/// redirected to /splash. Set by the redirect callback on first
|
||||||
/// invocation.
|
/// invocation.
|
||||||
@@ -188,9 +197,8 @@ class _MyAppState extends State<MyApp> {
|
|||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/poll/:id',
|
path: '/poll/:id',
|
||||||
builder: (context, state) => PollScreen(
|
builder: (context, state) =>
|
||||||
transactionId: state.pathParameters['id'],
|
PollScreen(transactionId: state.pathParameters['id']),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/poll',
|
path: '/poll',
|
||||||
@@ -222,6 +230,43 @@ class _MyAppState extends State<MyApp> {
|
|||||||
path: '/profile',
|
path: '/profile',
|
||||||
builder: (context, state) => const ProfileScreen(),
|
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(
|
GoRoute(
|
||||||
path: '/onboarding/landing',
|
path: '/onboarding/landing',
|
||||||
builder: (context, state) => const LandingScreen(),
|
builder: (context, state) => const LandingScreen(),
|
||||||
@@ -256,4 +301,4 @@ class _MyAppState extends State<MyApp> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
|||||||
extended: true,
|
extended: true,
|
||||||
leading: Image(
|
leading: Image(
|
||||||
image: AssetImage('assets/velocity.png'),
|
image: AssetImage('assets/velocity.png'),
|
||||||
width: 150
|
width: 150,
|
||||||
),
|
),
|
||||||
trailing: _buildLogin(context),
|
trailing: _buildLogin(context),
|
||||||
labelType: NavigationRailLabelType.none,
|
labelType: NavigationRailLabelType.none,
|
||||||
@@ -121,6 +121,11 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
|||||||
"selectedIcon": Icon(Icons.home),
|
"selectedIcon": Icon(Icons.home),
|
||||||
"label": 'Home',
|
"label": 'Home',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"icon": Icon(Icons.group_outlined),
|
||||||
|
"selectedIcon": Icon(Icons.group),
|
||||||
|
"label": 'Groups',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"icon": Icon(Icons.history_outlined),
|
"icon": Icon(Icons.history_outlined),
|
||||||
"selectedIcon": Icon(Icons.history),
|
"selectedIcon": Icon(Icons.history),
|
||||||
@@ -130,7 +135,7 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildLogin(BuildContext context) {
|
Widget _buildLogin(BuildContext context) {
|
||||||
if(_isLoggedIn) {
|
if (_isLoggedIn) {
|
||||||
return SizedBox();
|
return SizedBox();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,23 +189,19 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
|||||||
case 0:
|
case 0:
|
||||||
context.go('/home');
|
context.go('/home');
|
||||||
break;
|
break;
|
||||||
// case 1:
|
|
||||||
// context.go('/recipients');
|
|
||||||
// break;
|
|
||||||
case 1:
|
case 1:
|
||||||
|
context.go('/groups');
|
||||||
|
break;
|
||||||
|
case 2:
|
||||||
context.go('/history');
|
context.go('/history');
|
||||||
break;
|
break;
|
||||||
// case 2:
|
|
||||||
// context.go('/profile');
|
|
||||||
// break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
int _calculateSelectedIndex(BuildContext context) {
|
int _calculateSelectedIndex(BuildContext context) {
|
||||||
final String location = GoRouterState.of(context).uri.path;
|
final String location = GoRouterState.of(context).uri.path;
|
||||||
// if (location.startsWith('/recipients')) return 1;
|
if (location.startsWith('/groups')) return 1;
|
||||||
if (location.startsWith('/history')) return 1;
|
if (location.startsWith('/history')) return 2;
|
||||||
// if (location.startsWith('/profile')) return 2;
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
478
lib/screens/groups/batch_create_screen.dart
Normal file
478
lib/screens/groups/batch_create_screen.dart
Normal 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
1353
lib/screens/groups/batch_detail_screen.dart
Normal file
1353
lib/screens/groups/batch_detail_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
172
lib/screens/groups/batch_item_screen.dart
Normal file
172
lib/screens/groups/batch_item_screen.dart
Normal 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))),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
495
lib/screens/groups/controllers/batch_controller.dart
Normal file
495
lib/screens/groups/controllers/batch_controller.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
255
lib/screens/groups/controllers/group_controller.dart
Normal file
255
lib/screens/groups/controllers/group_controller.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
223
lib/screens/groups/group_create_screen.dart
Normal file
223
lib/screens/groups/group_create_screen.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
808
lib/screens/groups/group_detail_screen.dart
Normal file
808
lib/screens/groups/group_detail_screen.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
426
lib/screens/groups/groups_screen.dart
Normal file
426
lib/screens/groups/groups_screen.dart
Normal 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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
143
lib/screens/groups/models/group_batch_model.dart
Normal file
143
lib/screens/groups/models/group_batch_model.dart
Normal 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);
|
||||||
|
}
|
||||||
128
lib/screens/groups/models/group_batch_model.g.dart
Normal file
128
lib/screens/groups/models/group_batch_model.g.dart
Normal 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,
|
||||||
|
};
|
||||||
72
lib/screens/groups/models/recipient_group_model.dart
Normal file
72
lib/screens/groups/models/recipient_group_model.dart
Normal 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);
|
||||||
|
}
|
||||||
67
lib/screens/groups/models/recipient_group_model.g.dart
Normal file
67
lib/screens/groups/models/recipient_group_model.g.dart
Normal 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(),
|
||||||
|
};
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:qpay/models/responsive_policy.dart';
|
import 'package:qpay/models/responsive_policy.dart';
|
||||||
import 'package:qpay/screens/onboarding/login/login_controller.dart';
|
|
||||||
|
|
||||||
class CompleteScreen extends StatefulWidget {
|
class CompleteScreen extends StatefulWidget {
|
||||||
const CompleteScreen({super.key});
|
const CompleteScreen({super.key});
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Animated info card section
|
// Animated info card section
|
||||||
FadeTransition(
|
FadeTransition(
|
||||||
@@ -272,7 +272,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 14),
|
||||||
|
|
||||||
// Animated content section
|
// Animated content section
|
||||||
FadeTransition(
|
FadeTransition(
|
||||||
@@ -386,7 +386,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
|||||||
],
|
],
|
||||||
stops: const [0.0, 0.5, 1.0],
|
stops: const [0.0, 0.5, 1.0],
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: primaryColor.withValues(alpha: 0.12),
|
color: primaryColor.withValues(alpha: 0.12),
|
||||||
width: 1,
|
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(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// Status badge
|
// Status badge
|
||||||
if (status != null) ...[
|
if (status != null) ...[
|
||||||
_buildStatusBadge(status, theme),
|
_buildStatusBadge(status, theme),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 12),
|
||||||
],
|
],
|
||||||
|
|
||||||
// Amount row
|
// Amount row
|
||||||
@@ -433,14 +433,14 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
// Bill name (if available)
|
// Bill name (if available)
|
||||||
if (billName.isNotEmpty)
|
if (billName.isNotEmpty)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 14,
|
horizontal: 12,
|
||||||
vertical: 6,
|
vertical: 4,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: primaryColor.withValues(alpha: 0.08),
|
color: primaryColor.withValues(alpha: 0.08),
|
||||||
@@ -455,7 +455,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 4),
|
||||||
|
|
||||||
// Date
|
// Date
|
||||||
if (createdAt != null)
|
if (createdAt != null)
|
||||||
@@ -467,11 +467,11 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
|||||||
color: isDark ? Colors.white54 : Colors.grey.shade500,
|
color: isDark ? Colors.white54 : Colors.grey.shade500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// Divider with dots pattern
|
// Divider with dots pattern
|
||||||
_buildDottedDivider(isDark),
|
_buildDottedDivider(isDark),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 14),
|
||||||
|
|
||||||
// Action buttons row
|
// Action buttons row
|
||||||
_buildActionButtons(theme),
|
_buildActionButtons(theme),
|
||||||
@@ -487,7 +487,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
|||||||
Widget _buildDottedDivider(bool isDark) {
|
Widget _buildDottedDivider(bool isDark) {
|
||||||
return Row(
|
return Row(
|
||||||
children: List.generate(
|
children: List.generate(
|
||||||
60,
|
40,
|
||||||
(index) => Expanded(
|
(index) => Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
@@ -508,8 +508,8 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
|||||||
final paymentStatus = _transaction?.paymentStatus;
|
final paymentStatus = _transaction?.paymentStatus;
|
||||||
|
|
||||||
return Wrap(
|
return Wrap(
|
||||||
spacing: 12,
|
spacing: 10,
|
||||||
runSpacing: 8,
|
runSpacing: 6,
|
||||||
alignment: WrapAlignment.center,
|
alignment: WrapAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
_modernActionButton(
|
_modernActionButton(
|
||||||
@@ -926,7 +926,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(14),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -954,7 +954,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Divider
|
// Divider
|
||||||
Container(
|
Container(
|
||||||
@@ -963,12 +963,12 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
|||||||
? Colors.white.withValues(alpha: 0.06)
|
? Colors.white.withValues(alpha: 0.06)
|
||||||
: Colors.grey.shade200,
|
: Colors.grey.shade200,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 12),
|
||||||
|
|
||||||
// Detail items
|
// Detail items
|
||||||
...items.map(
|
...items.map(
|
||||||
(item) => Padding(
|
(item) => Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 14),
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
child: _buildModernDetailRow(
|
child: _buildModernDetailRow(
|
||||||
theme: theme,
|
theme: theme,
|
||||||
isDark: isDark,
|
isDark: isDark,
|
||||||
@@ -980,7 +980,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
|||||||
// Total amount divider & total
|
// Total amount divider & total
|
||||||
if (totalAmount != null) ...[
|
if (totalAmount != null) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 1,
|
height: 1,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -993,7 +993,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 8),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@@ -1111,4 +1111,4 @@ class _DetailItem {
|
|||||||
this.isWarning = false,
|
this.isWarning = false,
|
||||||
this.trailing,
|
this.trailing,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -20,14 +20,14 @@ class RecipientModel {
|
|||||||
@freezed
|
@freezed
|
||||||
abstract class Recipient with _$Recipient {
|
abstract class Recipient with _$Recipient {
|
||||||
const factory Recipient({
|
const factory Recipient({
|
||||||
String? uid,
|
@JsonKey(includeIfNull: false) String? uid,
|
||||||
String? name,
|
@JsonKey(includeIfNull: false) String? name,
|
||||||
String? email,
|
@JsonKey(includeIfNull: false) String? email,
|
||||||
String? phoneNumber,
|
@JsonKey(includeIfNull: false) String? phoneNumber,
|
||||||
String? address,
|
@JsonKey(includeIfNull: false) String? address,
|
||||||
String? account,
|
@JsonKey(includeIfNull: false) String? account,
|
||||||
String? initials,
|
@JsonKey(includeIfNull: false) String? initials,
|
||||||
String? latestProviderLabel,
|
@JsonKey(includeIfNull: false) String? latestProviderLabel,
|
||||||
}) = _Recipient;
|
}) = _Recipient;
|
||||||
|
|
||||||
factory Recipient.fromJson(Map<String, dynamic> json) =>
|
factory Recipient.fromJson(Map<String, dynamic> json) =>
|
||||||
@@ -40,14 +40,15 @@ class RecipientsController extends ChangeNotifier {
|
|||||||
BuildContext context;
|
BuildContext context;
|
||||||
|
|
||||||
RecipientsController(this.context) {
|
RecipientsController(this.context) {
|
||||||
model.selectedProvider =
|
model.selectedProvider = Provider.of<TransactionController>(
|
||||||
Provider.of<TransactionController>(
|
context,
|
||||||
context,
|
listen: false,
|
||||||
listen: false,
|
).model.selectedProvider;
|
||||||
).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.errorMessage = null;
|
||||||
model.isLoading = true;
|
model.isLoading = true;
|
||||||
model.recipients = getDummyRecipients();
|
model.recipients = getDummyRecipients();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
|
|||||||
/// @nodoc
|
/// @nodoc
|
||||||
mixin _$Recipient {
|
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
|
/// Create a copy of Recipient
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// with the given fields replaced by the non-null parameter values.
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
@@ -48,7 +48,7 @@ abstract mixin class $RecipientCopyWith<$Res> {
|
|||||||
factory $RecipientCopyWith(Recipient value, $Res Function(Recipient) _then) = _$RecipientCopyWithImpl;
|
factory $RecipientCopyWith(Recipient value, $Res Function(Recipient) _then) = _$RecipientCopyWithImpl;
|
||||||
@useResult
|
@useResult
|
||||||
$Res call({
|
$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) {
|
switch (_that) {
|
||||||
case _Recipient() when $default != null:
|
case _Recipient() when $default != null:
|
||||||
return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address,_that.account,_that.initials,_that.latestProviderLabel);case _:
|
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) {
|
switch (_that) {
|
||||||
case _Recipient():
|
case _Recipient():
|
||||||
return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address,_that.account,_that.initials,_that.latestProviderLabel);case _:
|
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) {
|
switch (_that) {
|
||||||
case _Recipient() when $default != null:
|
case _Recipient() when $default != null:
|
||||||
return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address,_that.account,_that.initials,_that.latestProviderLabel);case _:
|
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()
|
@JsonSerializable()
|
||||||
|
|
||||||
class _Recipient implements Recipient {
|
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);
|
factory _Recipient.fromJson(Map<String, dynamic> json) => _$RecipientFromJson(json);
|
||||||
|
|
||||||
@override final String? uid;
|
@override@JsonKey(includeIfNull: false) final String? uid;
|
||||||
@override final String? name;
|
@override@JsonKey(includeIfNull: false) final String? name;
|
||||||
@override final String? email;
|
@override@JsonKey(includeIfNull: false) final String? email;
|
||||||
@override final String? phoneNumber;
|
@override@JsonKey(includeIfNull: false) final String? phoneNumber;
|
||||||
@override final String? address;
|
@override@JsonKey(includeIfNull: false) final String? address;
|
||||||
@override final String? account;
|
@override@JsonKey(includeIfNull: false) final String? account;
|
||||||
@override final String? initials;
|
@override@JsonKey(includeIfNull: false) final String? initials;
|
||||||
@override final String? latestProviderLabel;
|
@override@JsonKey(includeIfNull: false) final String? latestProviderLabel;
|
||||||
|
|
||||||
/// Create a copy of Recipient
|
/// Create a copy of Recipient
|
||||||
/// with the given fields replaced by the non-null parameter values.
|
/// 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;
|
factory _$RecipientCopyWith(_Recipient value, $Res Function(_Recipient) _then) = __$RecipientCopyWithImpl;
|
||||||
@override @useResult
|
@override @useResult
|
||||||
$Res call({
|
$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
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,12 +19,12 @@ _Recipient _$RecipientFromJson(Map<String, dynamic> json) => _Recipient(
|
|||||||
|
|
||||||
Map<String, dynamic> _$RecipientToJson(_Recipient instance) =>
|
Map<String, dynamic> _$RecipientToJson(_Recipient instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
'uid': instance.uid,
|
'uid': ?instance.uid,
|
||||||
'name': instance.name,
|
'name': ?instance.name,
|
||||||
'email': instance.email,
|
'email': ?instance.email,
|
||||||
'phoneNumber': instance.phoneNumber,
|
'phoneNumber': ?instance.phoneNumber,
|
||||||
'address': instance.address,
|
'address': ?instance.address,
|
||||||
'account': instance.account,
|
'account': ?instance.account,
|
||||||
'initials': instance.initials,
|
'initials': ?instance.initials,
|
||||||
'latestProviderLabel': instance.latestProviderLabel,
|
'latestProviderLabel': ?instance.latestProviderLabel,
|
||||||
};
|
};
|
||||||
|
|||||||
44
lib/widgets/status_chip_widget.dart
Normal file
44
lib/widgets/status_chip_widget.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user