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());
|
||||
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/transaction_controller.dart';
|
||||
import 'package:qpay/screens/profile_screen.dart';
|
||||
import 'package:qpay/screens/groups/groups_screen.dart';
|
||||
import 'package:qpay/screens/groups/group_create_screen.dart';
|
||||
import 'package:qpay/screens/groups/group_detail_screen.dart';
|
||||
import 'package:qpay/screens/groups/batch_create_screen.dart';
|
||||
import 'package:qpay/screens/groups/batch_detail_screen.dart';
|
||||
import 'package:qpay/screens/groups/batch_item_screen.dart';
|
||||
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
|
||||
import 'package:qpay/screens/groups/models/group_batch_model.dart';
|
||||
import 'package:qpay/theme/app_theme.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
@@ -49,6 +57,7 @@ String resolveEnvFile(String env) {
|
||||
/// navigation to the originally intended URL.
|
||||
class SplashState extends ChangeNotifier {
|
||||
bool _complete = false;
|
||||
|
||||
/// The route the user originally intended to visit before being
|
||||
/// redirected to /splash. Set by the redirect callback on first
|
||||
/// invocation.
|
||||
@@ -188,9 +197,8 @@ class _MyAppState extends State<MyApp> {
|
||||
),
|
||||
GoRoute(
|
||||
path: '/poll/:id',
|
||||
builder: (context, state) => PollScreen(
|
||||
transactionId: state.pathParameters['id'],
|
||||
),
|
||||
builder: (context, state) =>
|
||||
PollScreen(transactionId: state.pathParameters['id']),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/poll',
|
||||
@@ -222,6 +230,43 @@ class _MyAppState extends State<MyApp> {
|
||||
path: '/profile',
|
||||
builder: (context, state) => const ProfileScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/groups',
|
||||
builder: (context, state) => const GroupsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/groups/create',
|
||||
builder: (context, state) => const GroupCreateScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/groups/:groupId',
|
||||
builder: (context, state) {
|
||||
final group = state.extra as RecipientGroup;
|
||||
return GroupDetailScreen(group: group);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/groups/:groupId/batches/create',
|
||||
builder: (context, state) {
|
||||
final group = state.extra as RecipientGroup;
|
||||
return BatchCreateScreen(group: group);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/groups/:groupId/batches/:batchId',
|
||||
builder: (context, state) {
|
||||
final batchId = state.pathParameters['batchId']!;
|
||||
final groupId = state.pathParameters['groupId']!;
|
||||
return BatchDetailScreen(batchId: batchId, groupId: groupId);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/groups/:groupId/batches/:batchId/items/:itemId',
|
||||
builder: (context, state) {
|
||||
final item = state.extra as GroupBatchItem;
|
||||
return BatchItemScreen(item: item);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding/landing',
|
||||
builder: (context, state) => const LandingScreen(),
|
||||
|
||||
@@ -47,7 +47,7 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
||||
extended: true,
|
||||
leading: Image(
|
||||
image: AssetImage('assets/velocity.png'),
|
||||
width: 150
|
||||
width: 150,
|
||||
),
|
||||
trailing: _buildLogin(context),
|
||||
labelType: NavigationRailLabelType.none,
|
||||
@@ -121,6 +121,11 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
||||
"selectedIcon": Icon(Icons.home),
|
||||
"label": 'Home',
|
||||
},
|
||||
{
|
||||
"icon": Icon(Icons.group_outlined),
|
||||
"selectedIcon": Icon(Icons.group),
|
||||
"label": 'Groups',
|
||||
},
|
||||
{
|
||||
"icon": Icon(Icons.history_outlined),
|
||||
"selectedIcon": Icon(Icons.history),
|
||||
@@ -130,7 +135,7 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
||||
}
|
||||
|
||||
Widget _buildLogin(BuildContext context) {
|
||||
if(_isLoggedIn) {
|
||||
if (_isLoggedIn) {
|
||||
return SizedBox();
|
||||
}
|
||||
|
||||
@@ -184,23 +189,19 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
||||
case 0:
|
||||
context.go('/home');
|
||||
break;
|
||||
// case 1:
|
||||
// context.go('/recipients');
|
||||
// break;
|
||||
case 1:
|
||||
context.go('/groups');
|
||||
break;
|
||||
case 2:
|
||||
context.go('/history');
|
||||
break;
|
||||
// case 2:
|
||||
// context.go('/profile');
|
||||
// break;
|
||||
}
|
||||
}
|
||||
|
||||
int _calculateSelectedIndex(BuildContext context) {
|
||||
final String location = GoRouterState.of(context).uri.path;
|
||||
// if (location.startsWith('/recipients')) return 1;
|
||||
if (location.startsWith('/history')) return 1;
|
||||
// if (location.startsWith('/profile')) return 2;
|
||||
if (location.startsWith('/groups')) return 1;
|
||||
if (location.startsWith('/history')) return 2;
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
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:go_router/go_router.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/onboarding/login/login_controller.dart';
|
||||
|
||||
class CompleteScreen extends StatefulWidget {
|
||||
const CompleteScreen({super.key});
|
||||
|
||||
@@ -259,7 +259,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Animated info card section
|
||||
FadeTransition(
|
||||
@@ -272,7 +272,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Animated content section
|
||||
FadeTransition(
|
||||
@@ -386,7 +386,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
],
|
||||
stops: const [0.0, 0.5, 1.0],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: primaryColor.withValues(alpha: 0.12),
|
||||
width: 1,
|
||||
@@ -399,13 +399,13 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 24),
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
// Status badge
|
||||
if (status != null) ...[
|
||||
_buildStatusBadge(status, theme),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
// Amount row
|
||||
@@ -433,14 +433,14 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Bill name (if available)
|
||||
if (billName.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 6,
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: primaryColor.withValues(alpha: 0.08),
|
||||
@@ -455,7 +455,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Date
|
||||
if (createdAt != null)
|
||||
@@ -467,11 +467,11 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
color: isDark ? Colors.white54 : Colors.grey.shade500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Divider with dots pattern
|
||||
_buildDottedDivider(isDark),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Action buttons row
|
||||
_buildActionButtons(theme),
|
||||
@@ -487,7 +487,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
Widget _buildDottedDivider(bool isDark) {
|
||||
return Row(
|
||||
children: List.generate(
|
||||
60,
|
||||
40,
|
||||
(index) => Expanded(
|
||||
child: Container(
|
||||
height: 1.5,
|
||||
@@ -508,8 +508,8 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
final paymentStatus = _transaction?.paymentStatus;
|
||||
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
spacing: 10,
|
||||
runSpacing: 6,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
_modernActionButton(
|
||||
@@ -926,7 +926,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -954,7 +954,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Divider
|
||||
Container(
|
||||
@@ -963,12 +963,12 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
? Colors.white.withValues(alpha: 0.06)
|
||||
: Colors.grey.shade200,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Detail items
|
||||
...items.map(
|
||||
(item) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _buildModernDetailRow(
|
||||
theme: theme,
|
||||
isDark: isDark,
|
||||
@@ -980,7 +980,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
// Total amount divider & total
|
||||
if (totalAmount != null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Container(
|
||||
height: 1,
|
||||
decoration: BoxDecoration(
|
||||
@@ -993,7 +993,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
|
||||
@@ -20,14 +20,14 @@ class RecipientModel {
|
||||
@freezed
|
||||
abstract class Recipient with _$Recipient {
|
||||
const factory Recipient({
|
||||
String? uid,
|
||||
String? name,
|
||||
String? email,
|
||||
String? phoneNumber,
|
||||
String? address,
|
||||
String? account,
|
||||
String? initials,
|
||||
String? latestProviderLabel,
|
||||
@JsonKey(includeIfNull: false) String? uid,
|
||||
@JsonKey(includeIfNull: false) String? name,
|
||||
@JsonKey(includeIfNull: false) String? email,
|
||||
@JsonKey(includeIfNull: false) String? phoneNumber,
|
||||
@JsonKey(includeIfNull: false) String? address,
|
||||
@JsonKey(includeIfNull: false) String? account,
|
||||
@JsonKey(includeIfNull: false) String? initials,
|
||||
@JsonKey(includeIfNull: false) String? latestProviderLabel,
|
||||
}) = _Recipient;
|
||||
|
||||
factory Recipient.fromJson(Map<String, dynamic> json) =>
|
||||
@@ -40,14 +40,15 @@ class RecipientsController extends ChangeNotifier {
|
||||
BuildContext context;
|
||||
|
||||
RecipientsController(this.context) {
|
||||
model.selectedProvider =
|
||||
Provider.of<TransactionController>(
|
||||
model.selectedProvider = Provider.of<TransactionController>(
|
||||
context,
|
||||
listen: false,
|
||||
).model.selectedProvider;
|
||||
}
|
||||
|
||||
Future<ApiResponse<List<Recipient>>> getRecipients([Map<String, String>? params]) async {
|
||||
Future<ApiResponse<List<Recipient>>> getRecipients([
|
||||
Map<String, String>? params,
|
||||
]) async {
|
||||
model.errorMessage = null;
|
||||
model.isLoading = true;
|
||||
model.recipients = getDummyRecipients();
|
||||
|
||||
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
mixin _$Recipient {
|
||||
|
||||
String? get uid; String? get name; String? get email; String? get phoneNumber; String? get address; String? get account; String? get initials; String? get latestProviderLabel;
|
||||
@JsonKey(includeIfNull: false) String? get uid;@JsonKey(includeIfNull: false) String? get name;@JsonKey(includeIfNull: false) String? get email;@JsonKey(includeIfNull: false) String? get phoneNumber;@JsonKey(includeIfNull: false) String? get address;@JsonKey(includeIfNull: false) String? get account;@JsonKey(includeIfNull: false) String? get initials;@JsonKey(includeIfNull: false) String? get latestProviderLabel;
|
||||
/// Create a copy of Recipient
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -48,7 +48,7 @@ abstract mixin class $RecipientCopyWith<$Res> {
|
||||
factory $RecipientCopyWith(Recipient value, $Res Function(Recipient) _then) = _$RecipientCopyWithImpl;
|
||||
@useResult
|
||||
$Res call({
|
||||
String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel
|
||||
@JsonKey(includeIfNull: false) String? uid,@JsonKey(includeIfNull: false) String? name,@JsonKey(includeIfNull: false) String? email,@JsonKey(includeIfNull: false) String? phoneNumber,@JsonKey(includeIfNull: false) String? address,@JsonKey(includeIfNull: false) String? account,@JsonKey(includeIfNull: false) String? initials,@JsonKey(includeIfNull: false) String? latestProviderLabel
|
||||
});
|
||||
|
||||
|
||||
@@ -160,7 +160,7 @@ return $default(_that);case _:
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function(@JsonKey(includeIfNull: false) String? uid, @JsonKey(includeIfNull: false) String? name, @JsonKey(includeIfNull: false) String? email, @JsonKey(includeIfNull: false) String? phoneNumber, @JsonKey(includeIfNull: false) String? address, @JsonKey(includeIfNull: false) String? account, @JsonKey(includeIfNull: false) String? initials, @JsonKey(includeIfNull: false) String? latestProviderLabel)? $default,{required TResult orElse(),}) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Recipient() when $default != null:
|
||||
return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address,_that.account,_that.initials,_that.latestProviderLabel);case _:
|
||||
@@ -181,7 +181,7 @@ return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel) $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function(@JsonKey(includeIfNull: false) String? uid, @JsonKey(includeIfNull: false) String? name, @JsonKey(includeIfNull: false) String? email, @JsonKey(includeIfNull: false) String? phoneNumber, @JsonKey(includeIfNull: false) String? address, @JsonKey(includeIfNull: false) String? account, @JsonKey(includeIfNull: false) String? initials, @JsonKey(includeIfNull: false) String? latestProviderLabel) $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Recipient():
|
||||
return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address,_that.account,_that.initials,_that.latestProviderLabel);case _:
|
||||
@@ -201,7 +201,7 @@ return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address
|
||||
/// }
|
||||
/// ```
|
||||
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel)? $default,) {final _that = this;
|
||||
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function(@JsonKey(includeIfNull: false) String? uid, @JsonKey(includeIfNull: false) String? name, @JsonKey(includeIfNull: false) String? email, @JsonKey(includeIfNull: false) String? phoneNumber, @JsonKey(includeIfNull: false) String? address, @JsonKey(includeIfNull: false) String? account, @JsonKey(includeIfNull: false) String? initials, @JsonKey(includeIfNull: false) String? latestProviderLabel)? $default,) {final _that = this;
|
||||
switch (_that) {
|
||||
case _Recipient() when $default != null:
|
||||
return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address,_that.account,_that.initials,_that.latestProviderLabel);case _:
|
||||
@@ -216,17 +216,17 @@ return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address
|
||||
@JsonSerializable()
|
||||
|
||||
class _Recipient implements Recipient {
|
||||
const _Recipient({this.uid, this.name, this.email, this.phoneNumber, this.address, this.account, this.initials, this.latestProviderLabel});
|
||||
const _Recipient({@JsonKey(includeIfNull: false) this.uid, @JsonKey(includeIfNull: false) this.name, @JsonKey(includeIfNull: false) this.email, @JsonKey(includeIfNull: false) this.phoneNumber, @JsonKey(includeIfNull: false) this.address, @JsonKey(includeIfNull: false) this.account, @JsonKey(includeIfNull: false) this.initials, @JsonKey(includeIfNull: false) this.latestProviderLabel});
|
||||
factory _Recipient.fromJson(Map<String, dynamic> json) => _$RecipientFromJson(json);
|
||||
|
||||
@override final String? uid;
|
||||
@override final String? name;
|
||||
@override final String? email;
|
||||
@override final String? phoneNumber;
|
||||
@override final String? address;
|
||||
@override final String? account;
|
||||
@override final String? initials;
|
||||
@override final String? latestProviderLabel;
|
||||
@override@JsonKey(includeIfNull: false) final String? uid;
|
||||
@override@JsonKey(includeIfNull: false) final String? name;
|
||||
@override@JsonKey(includeIfNull: false) final String? email;
|
||||
@override@JsonKey(includeIfNull: false) final String? phoneNumber;
|
||||
@override@JsonKey(includeIfNull: false) final String? address;
|
||||
@override@JsonKey(includeIfNull: false) final String? account;
|
||||
@override@JsonKey(includeIfNull: false) final String? initials;
|
||||
@override@JsonKey(includeIfNull: false) final String? latestProviderLabel;
|
||||
|
||||
/// Create a copy of Recipient
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@@ -261,7 +261,7 @@ abstract mixin class _$RecipientCopyWith<$Res> implements $RecipientCopyWith<$Re
|
||||
factory _$RecipientCopyWith(_Recipient value, $Res Function(_Recipient) _then) = __$RecipientCopyWithImpl;
|
||||
@override @useResult
|
||||
$Res call({
|
||||
String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel
|
||||
@JsonKey(includeIfNull: false) String? uid,@JsonKey(includeIfNull: false) String? name,@JsonKey(includeIfNull: false) String? email,@JsonKey(includeIfNull: false) String? phoneNumber,@JsonKey(includeIfNull: false) String? address,@JsonKey(includeIfNull: false) String? account,@JsonKey(includeIfNull: false) String? initials,@JsonKey(includeIfNull: false) String? latestProviderLabel
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -19,12 +19,12 @@ _Recipient _$RecipientFromJson(Map<String, dynamic> json) => _Recipient(
|
||||
|
||||
Map<String, dynamic> _$RecipientToJson(_Recipient instance) =>
|
||||
<String, dynamic>{
|
||||
'uid': instance.uid,
|
||||
'name': instance.name,
|
||||
'email': instance.email,
|
||||
'phoneNumber': instance.phoneNumber,
|
||||
'address': instance.address,
|
||||
'account': instance.account,
|
||||
'initials': instance.initials,
|
||||
'latestProviderLabel': instance.latestProviderLabel,
|
||||
'uid': ?instance.uid,
|
||||
'name': ?instance.name,
|
||||
'email': ?instance.email,
|
||||
'phoneNumber': ?instance.phoneNumber,
|
||||
'address': ?instance.address,
|
||||
'account': ?instance.account,
|
||||
'initials': ?instance.initials,
|
||||
'latestProviderLabel': ?instance.latestProviderLabel,
|
||||
};
|
||||
|
||||
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