Compare commits

..

33 Commits

Author SHA1 Message Date
3023bc313c usability fixes 2026-06-25 01:45:23 +02:00
78f64c0af6 update build number 2026-06-25 00:32:46 +02:00
0201a78f17 improving error handling 2026-06-25 00:30:46 +02:00
664cefcecd improving error handling 2026-06-25 00:28:45 +02:00
a31e38765d added pagination to group and batch detail screens 2026-06-24 23:19:45 +02:00
263c10d2bb updating UI inconsistency 2026-06-24 19:11:33 +02:00
57358f6233 updating UI inconsistency 2026-06-24 18:31:21 +02:00
a6b4a04bd5 usability fixes 2026-06-24 18:01:34 +02:00
9e55ec1097 updating uptime 2026-06-24 10:48:59 +02:00
a79cfadf51 minor fixes 2026-06-24 00:59:19 +02:00
5a5aab6956 updating build number 2026-06-24 00:38:09 +02:00
1866e60518 added uptime monitoring 2026-06-24 00:31:01 +02:00
e89c711d00 added navigation and selection of workspaces 2026-06-23 00:22:30 +02:00
ae2705363a completed migrating data scope from user to workspace 2026-06-22 22:10:38 +02:00
Prince
62c7f53de0 minor bug fixes 2026-06-22 10:06:51 +02:00
Prince
eca22cd6a3 fixing splash screen 2026-06-21 20:27:45 +02:00
Prince
101486cb1e minor fixes 2026-06-21 20:18:12 +02:00
Prince
7de44de068 minor fixes 2026-06-21 20:16:21 +02:00
Prince
b90d95bc63 bug fix on batches for zesa 2026-06-21 00:24:36 +02:00
Prince
381b5fbf08 added batch file upload logic 2026-06-20 01:05:35 +02:00
Prince
bb9dbac2a3 updated branding & added analytics 2026-06-19 12:44:57 +02:00
Prince
f3ae5ca791 minor fixes on status responses 2026-06-17 21:35:15 +02:00
Prince
64eaa38079 added zwg support 2026-06-17 14:42:46 +02:00
Prince
cc4b02f3c9 completed batch feature 2026-06-17 00:55:20 +02:00
Prince
31f403aa10 minor fixes 2026-06-15 22:04:59 +02:00
Prince
8b78199992 added android logo assets 2026-06-14 20:34:59 +02:00
Prince
5014b679f5 fixing android build 2026-06-14 13:09:14 +02:00
Prince
7dc48e06e9 minor home screen fixes 2026-06-13 20:23:06 +02:00
Prince
d36207bc54 minor home screen fixes 2026-06-13 00:05:47 +02:00
Prince
77236a11d9 improving polling logic 2026-06-12 23:48:21 +02:00
Prince
202756a6bb added otp resend cooldown 2026-06-12 11:56:00 +02:00
Prince
4386fb6036 updating env files 2026-06-12 10:50:01 +02:00
Prince
6af2d4f7b2 updating env files 2026-06-12 00:19:45 +02:00
147 changed files with 11629 additions and 3498 deletions

16
.vscode/launch.json vendored
View File

@@ -4,14 +4,26 @@
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0", "version": "0.2.0",
"configurations": [ "configurations": [
{ {
"name": "qpay", "name": "qpay",
"request": "launch", "request": "launch",
"type": "dart", "type": "dart",
"deviceId": "chrome", "deviceId": "chrome",
"args": [ "args": [
"--dart-define=APP_ENV=test", "--dart-define=APP_ENV=test",
"--web-port=9005" "--web-port=9005",
"--dart-define=BASE_URL=http://localhost:6950/api"
]
},
{
"name": "qpay - live",
"request": "launch",
"type": "dart",
"deviceId": "chrome",
"args": [
"--dart-define=APP_ENV=live",
"--web-port=9005",
"--dart-define=BASE_URL=https://payapi.velocityafrica.net/api"
] ]
}, },

View File

@@ -32,7 +32,12 @@ WORKDIR $APP
# Run build: 1 - clean, 2 - pub get, 3 - build web # Run build: 1 - clean, 2 - pub get, 3 - build web
RUN flutter clean RUN flutter clean
RUN flutter pub get RUN flutter pub get
RUN flutter build web --dart-define=APP_ENV=live RUN flutter build web \
--dart-define=APP_ENV=live \
--dart-define=BASE_URL=https://payapi.velocityafrica.net/api \
--dart-define=CLIENTID=77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com \
--dart-define=SIMULATE_PAYMENT_SUCCESS=false \
--dart-define=GATEWAY_SCRIPT_URL=https://na.gateway.mastercard.com/static/checkout/checkout.min.js
# once heare the app will be compiled and ready to deploy # once heare the app will be compiled and ready to deploy

View File

@@ -0,0 +1,160 @@
# Audit: Migrating from `userId` to `workspaceId`
This document audits every place the project uses `userId` (and `user_id`) so the
migration to a `workspaceId`-scoped model can be done completely. There are
currently **no references to `workspaceId`** anywhere in the codebase — this is a
greenfield rename/replacement.
## How scoping currently works (the mental model)
1. **Source of truth set at login**`login_controller.dart:70` stores
`prefs.setString('userId', response['uuid'])`. The backend's `uuid` becomes
the locally cached `userId`.
2. **Anonymous fallback**`home_screen.dart:127,162` generate a random
`Uuid().v4()` and store it as `userId` when none exists (anonymous/guest flow).
3. **Consumption** — Almost everywhere else reads `prefs.getString("userId")`
and passes it to the backend as:
- a **query param** (`?userId=...` or `_queryParams['userId']`),
- a **request body field** (`BatchActionRequest.userId`, `FormData.userId`),
- a **model field** deserialized from `json['userId']`.
> Migration decision needed (see "Open questions"): is `workspaceId` a NEW value
> the backend returns at login, or simply a rename of the existing `uuid`? And
> will the API now accept/scope by a header (e.g. `X-Workspace-Id`) instead of a
> query param? The plan below assumes a rename + the option to centralize scoping.
---
## Inventory of changes required
### A. Persistence / key name (the keystone)
The SharedPreferences key string `"userId"` is the single most important thing to
change consistently. If the key string is changed in one place but not another,
reads will silently return `null`.
| File | Line | Current | Action |
|---|---|---|---|
| `lib/screens/onboarding/login/login_controller.dart` | 70 | `prefs.setString('userId', response['uuid'])` | Write `workspaceId`. Confirm backend field — may become `response['workspaceId']`. |
| `lib/screens/home/home_screen.dart` | 127, 162 | `prefs.setString("userId", Uuid().v4())` | Rename key to `workspaceId` (anonymous fallback). |
| `lib/screens/home/home_screen.dart` | 126, 130, 151, 152, 161, 165 | `prefs.getString("userId")` | Rename key reads to `workspaceId`. |
| `lib/screens/onboarding/phone/phone_controller.dart` | 48 | `"tempUid": prefs.get("userId")` | Rename key read; confirm whether `tempUid` body field name should change too. |
> Recommendation: introduce a single constant, e.g. `const kWorkspaceIdKey = 'workspaceId';`
> and a small helper (`Prefs.workspaceId`) so the key string is never duplicated again.
> A migration shim should also copy any existing `userId` value to `workspaceId` on
> first launch so logged-in users aren't logged out.
### B. Query-param / URL usages (sent to backend)
| File | Line | Current |
|---|---|---|
| `lib/screens/recipient/recipients_screen.dart` | 69, 271 | `_queryParams['userId'] = prefs.getString("userId")!` |
| `lib/screens/history/history_controller.dart` | 119, 146 | `'userId': userId` (query map) |
| `lib/screens/groups/controllers/group_controller.dart` | 72, 197, 233, 269 | `'userId': userId` and `?userId=$userId` in URLs |
| `lib/screens/groups/controllers/batch_controller.dart` | 369, 528, 584 | `?userId=$userId` in URLs |
| `lib/screens/home/home_controller.dart` | 158 | `...&userId=$userId` in URL |
Action: rename the param key to `workspaceId` (or move scoping into a header — see C).
### C. HTTP layer (`lib/http/http.dart`)
Currently the HTTP wrapper only adds `Authorization: Bearer <token>` headers
(`getHeaders()` around lines 2836). It does **not** know about `userId`.
Action / opportunity: if the backend now scopes by a workspace header, this is the
single best place to inject `X-Workspace-Id` once, eliminating the dozens of
per-call query params. Otherwise, leave the per-call params and just rename them.
### D. Request-body model fields & method params (Dart-side, hand-written)
These are the hand-written declarations and call sites to rename
(`userId` -> `workspaceId`):
- `lib/screens/groups/models/group_batch_model.dart` — fields at lines 9, 90, 117, 134 and constructors 26, 100, 121, 141 (includes `BatchActionRequest.userId`).
- `lib/screens/groups/models/recipient_group_model.dart` — fields 11, 33, 58 and constructors 18, 42, 64.
- `lib/screens/groups/models/batch_item_update_model.dart` — field 38, constructor 40.
- `lib/screens/transactions/transaction_model.dart` — field 53, constructor 101.
- `lib/screens/transaction_controller.dart` — defaults/params 41, 71, 152 (the freezed `FormData.userId`).
- `lib/screens/groups/controllers/group_controller.dart` — method params 56, 118, 123, 193, 229, 244, 257 and internal calls 120, 128, 251.
- `lib/screens/groups/controllers/batch_controller.dart` — method params 363, 466, 494, 522, 545, 581, 598 and `BatchActionRequest(userId: ...)` at 473, 501, 552; recursive call 606.
- `lib/screens/history/history_controller.dart` — method params 110, 141.
- `lib/screens/home/home_controller.dart` — method param 151.
### E. Call sites in screens (read prefs -> pass through)
- `lib/screens/pay/pay_controller.dart` — 101 (`userId: prefs.getString("userId") ?? ''` into FormData).
- `lib/screens/groups/screens/group_create_screen.dart` — 84, 91.
- `lib/screens/groups/screens/group_detail_screen.dart` — 555, 678, 703 (`widget.group.userId`).
- `lib/screens/groups/screens/batch_detail_screen.dart` — local `_userId` at 36, 88 and uses at 90, 139, 155, 434, 487, 675, 688, 1438.
- `lib/screens/groups/screens/create_group_from_file_screen.dart` — 68, 72.
- `lib/screens/groups/screens/groups_screen.dart` — 33, 39, 40, 44, 46, 52, 75.
- `lib/screens/groups/screens/batch_create_screen.dart` — 92, 96, 118, 133, 157.
- `lib/screens/history/history_screen.dart` — 67, 74, 230, 330, 354, 499, 512.
### F. Generated files (DO NOT hand-edit — regenerate)
These contain `userId` derived from `@JsonKey`/freezed annotations in the
hand-written sources above. After renaming the source fields (and the JSON keys),
regenerate with build_runner:
- `lib/screens/transaction_controller.g.dart`
- `lib/screens/transaction_controller.freezed.dart`
- `lib/screens/groups/models/batch_item_update_model.g.dart`
- `lib/screens/groups/models/recipient_group_model.g.dart`
- `lib/screens/groups/models/group_batch_model.g.dart`
- `lib/screens/transactions/transaction_model.g.dart`
Regenerate command:
```
dart run build_runner build --delete-conflicting-outputs
```
---
## JSON wire-key decision (critical)
The generated serializers currently read/write the JSON key `'userId'`
(e.g. `json['userId']`, `'userId': instance.userId`). You must decide whether the
**backend JSON contract** also changes to `workspaceId`:
- If the API now expects/returns `workspaceId`: rename the Dart field AND let the
serializer use the new key (or add `@JsonKey(name: 'workspaceId')`).
- If the API still uses `userId` on the wire but you only want the Dart field
renamed: keep the wire key with `@JsonKey(name: 'userId')` on the renamed field.
This must be coordinated with the backend team before regenerating.
---
## Recommended migration order
1. Confirm backend contract (see Open questions) — header vs query param, and the
JSON key name.
2. Add a `kWorkspaceIdKey` constant + prefs helper, plus a one-time migration shim
that copies existing `userId` -> `workspaceId`.
3. (Optional but recommended) Inject `X-Workspace-Id` centrally in `http.dart`.
4. Rename hand-written model fields / method params (sections D & E).
5. Update query-param/URL usages (section B), or remove them if moved to header.
6. Update the prefs key reads/writes (section A).
7. Regenerate build_runner outputs (section F).
8. `flutter analyze` and test the anonymous flow, login flow, history, groups,
batches, recipients, and pay flows end-to-end.
## Open questions for the team
- Is `workspaceId` a brand-new value from the backend (e.g. `response['workspaceId']`),
or just a rename of the existing `uuid`/`userId`?
- Should scoping move to a request **header** (`X-Workspace-Id`) so it can be set
once in `http.dart`, instead of ~15 per-call query params/body fields?
- Does the JSON wire contract change to `workspaceId`, or stay `userId`?
- For the anonymous/guest flow (`home_screen.dart`), is a client-generated
`Uuid().v4()` still acceptable as a `workspaceId`, or must the backend mint it?
- Does `phone_controller`'s `tempUid` body key need renaming too?
## Summary counts
- Non-generated `.dart` files to edit: **21**
- Generated files to regenerate: **6**
- Total non-generated `userId`/`user_id` occurrences: **~112**
- Existing `workspaceId` references: **0**

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

View File

@@ -1,4 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/> <background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon> </adaptive-icon>

View File

@@ -1,4 +0,0 @@
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
</adaptive-icon>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 696 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -1,3 +0,0 @@
<resources>
<color name="ic_launcher_background">#ffffff</color>
</resources>

View File

@@ -1,3 +1,7 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true
# This builtInKotlin flag was added automatically by Flutter migrator
android.builtInKotlin=false
# This newDsl flag was added automatically by Flutter migrator
android.newDsl=false

View File

@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip

View File

@@ -18,7 +18,7 @@ pluginManagement {
plugins { plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.7.0" apply false id("com.android.application") version "8.9.1" apply false
// START: FlutterFire Configuration // START: FlutterFire Configuration
id("com.google.gms.google-services") version("4.3.15") apply false id("com.google.gms.google-services") version("4.3.15") apply false
// END: FlutterFire Configuration // END: FlutterFire Configuration

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

View File

@@ -0,0 +1,4 @@
name, account, phone,billLabel, billProductName,amount
John Doe,0773591219,0773591219,ECONET_BUNDLES,Daily Data Bundle (20MB),
Jane Smith,0773591219,0773591219,ECONET,,0.1
James Top,07088597534,0773591219,ZESA,,5
1 name account phone billLabel billProductName amount
2 John Doe 0773591219 0773591219 ECONET_BUNDLES Daily Data Bundle (20MB)
3 Jane Smith 0773591219 0773591219 ECONET 0.1
4 James Top 07088597534 0773591219 ZESA 5

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 MiB

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 MiB

After

Width:  |  Height:  |  Size: 671 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -1,4 +1,4 @@
BASE_URL=https://api.velocityafrica.net/api BASE_URL=https://payapi.velocityafrica.net/api
; BASE_URL=http://192.168.100.138:6950/api ; BASE_URL=http://192.168.100.138:6950/api
; BASE_URL=http://173.212.247.232:6950/api ; BASE_URL=http://173.212.247.232:6950/api
; BASE_URL=http://10.69.5.201:6950/api ; BASE_URL=http://10.69.5.201:6950/api

View File

@@ -1,5 +1,4 @@
BASE_URL=https://api.velocityafrica.net/api BASE_URL=https://payapi.velocityafrica.net/api
CLIENTID=77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com CLIENTID=77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com
SIMULATE_PAYMENT_SUCCESS=false SIMULATE_PAYMENT_SUCCESS=false
GATEWAY_SCRIPT_URL=https://na.gateway.mastercard.com/static/checkout/checkout.min.js GATEWAY_SCRIPT_URL=https://na.gateway.mastercard.com/static/checkout/checkout.min.js

View File

@@ -4,4 +4,3 @@ BASE_URL=http://localhost:6950/api
CLIENTID=77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com CLIENTID=77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com
SIMULATE_PAYMENT_SUCCESS=false SIMULATE_PAYMENT_SUCCESS=false
GATEWAY_SCRIPT_URL=https://test-gateway.mastercard.com/static/checkout/checkout.min.js GATEWAY_SCRIPT_URL=https://test-gateway.mastercard.com/static/checkout/checkout.min.js

14
lib/browser.dart Normal file
View File

@@ -0,0 +1,14 @@
/// Platform-aware browser API abstraction.
///
/// On web, provides real browser localStorage and location APIs.
/// On non-web platforms (Android, iOS, desktop), returns no-op stubs.
///
/// Usage:
/// ```dart
/// import 'package:qpay/browser.dart';
/// Browser.localStorage.setItem('key', 'value');
/// Browser.localStorage.getItem('key');
/// Browser.location.href = 'https://example.com';
/// ```
export 'platform/browser_stub.dart'
if (dart.library.html) 'platform/browser_web.dart';

View File

@@ -0,0 +1,53 @@
/// Application configuration sourced entirely from compile-time
/// `--dart-define` flags. No `.env` file is read at runtime, so no
/// secrets can leak into the web bundle via asset files.
///
/// Every value has a sensible default so the project still builds and
/// runs locally without specifying any flags.
class AppConfig {
AppConfig._();
// -----------------------------------------------------------------
// Compile-time values (set via `--dart-define=<KEY>=<VALUE>`)
// -----------------------------------------------------------------
/// Runtime environment: `test`, `live`, etc.
static const String env = String.fromEnvironment(
'APP_ENV',
defaultValue: 'test',
);
/// Base URL of the backend API.
static const String baseUrl = String.fromEnvironment(
'BASE_URL',
defaultValue: 'http://localhost:6950/api',
);
/// Google OAuth web client ID. This is *not* a secret — it is
/// intended to be embedded in public-facing web applications.
static const String clientId = String.fromEnvironment(
'CLIENTID',
defaultValue: '',
);
/// When `true` the payment gateway screens skip the real flow and
/// return a simulated success immediately. Useful during development.
static const bool simulatePaymentSuccess = bool.fromEnvironment(
'SIMULATE_PAYMENT_SUCCESS',
defaultValue: false,
);
/// URL of the Mastercard Gateway checkout JavaScript bundle.
static const String gatewayScriptUrl = String.fromEnvironment(
'GATEWAY_SCRIPT_URL',
defaultValue:
'https://test-gateway.mastercard.com/static/checkout/checkout.min.js',
);
// -----------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------
/// `true` when [env] is `live`.
static bool get isLive => env.toLowerCase() == 'live';
}

View File

@@ -1,14 +1,14 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:qpay/config/app_config.dart';
var logger = Logger(printer: PrettyPrinter()); var logger = Logger(printer: PrettyPrinter());
class Http { class Http {
final Dio dio = Dio(); final Dio dio = Dio();
final String baseUrl = dotenv.env['BASE_URL'] ?? ''; final String baseUrl = AppConfig.baseUrl;
Http() { Http() {
dio.options.baseUrl = baseUrl; dio.options.baseUrl = baseUrl;
@@ -45,8 +45,34 @@ class Http {
return response.data; return response.data;
} }
/// Returns the raw [Response] object (including status code)
/// instead of just the response body.
Future<Response> getRaw(String url) async {
Map<String, String> headers = await getHeaders();
final response = await dio.get(
baseUrl + url,
options: Options(headers: headers),
);
logger.i(response.data.toString());
return response;
}
/// Issues a POST request without authentication headers and returns
/// the raw [Response] object.
Future<Response> postRaw(String url, {dynamic data}) async {
Map<String, String> headers = await getHeaders();
final response = await dio.post(
baseUrl + url,
data: data,
options: Options(headers: headers),
);
logger.i(response.data.toString());
return response;
}
Future<dynamic> post(String url, dynamic data) async { Future<dynamic> post(String url, dynamic data) async {
Map<String, String> headers = {'Content-Type': 'application/json'}; Map<String, String> headers = await getHeaders();
Response response; Response response;
response = await dio.post( response = await dio.post(
@@ -58,6 +84,19 @@ class Http {
return response.data; return response.data;
} }
Future<dynamic> patch(String url, dynamic data) async {
Map<String, String> headers = await getHeaders();
Response response;
response = await dio.patch(
baseUrl + url,
data: data,
options: Options(headers: headers),
);
logger.i(response.data.toString());
return response.data;
}
Future<dynamic> put(String url, dynamic data) async { Future<dynamic> put(String url, dynamic data) async {
Map<String, String> headers = await getHeaders(); Map<String, String> headers = await getHeaders();
@@ -82,4 +121,28 @@ class Http {
logger.i(response.data.toString()); logger.i(response.data.toString());
return response.data; return response.data;
} }
Future<dynamic> multipartPost(
String url,
Map<String, String> fields,
String fileFieldName,
List<int> fileBytes,
String fileName,
) async {
final formData = FormData.fromMap({
...fields,
fileFieldName: MultipartFile.fromBytes(fileBytes, filename: fileName),
});
final prefs = await SharedPreferences.getInstance();
final token = prefs.getString("token") ?? "";
final response = await dio.post(
baseUrl + url,
data: formData,
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
logger.i(response.data.toString());
return response.data;
}
} }

View File

@@ -1,12 +1,9 @@
import 'dart:async'; import 'dart:async';
import 'dart:js_interop'; import 'dart:js_interop';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:qpay/config/app_config.dart';
import 'package:web/web.dart' as web; import 'package:web/web.dart' as web;
const String _defaultGatewayScriptUrl =
'https://test-gateway.mastercard.com/static/checkout/checkout.min.js';
@JS('Checkout') @JS('Checkout')
external JSAny? _checkout; external JSAny? _checkout;
@@ -23,8 +20,7 @@ Future<void> ensureCheckoutScriptLoaded() async {
return; return;
} }
final scriptUrl = final scriptUrl = AppConfig.gatewayScriptUrl;
dotenv.env['GATEWAY_SCRIPT_URL'] ?? _defaultGatewayScriptUrl;
final scriptElement = web.HTMLScriptElement() final scriptElement = web.HTMLScriptElement()
..id = 'mastercard-checkout-script' ..id = 'mastercard-checkout-script'

View File

@@ -1,36 +1,26 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:qpay/auth_state.dart'; import 'package:qpay/auth_state.dart';
import 'package:qpay/routes.dart'; import 'package:qpay/routes.dart';
import 'package:qpay/url_strategy.dart';
import 'package:qpay/screens/transaction_controller.dart'; import 'package:qpay/screens/transaction_controller.dart';
import 'package:qpay/screens/onboarding/onboarding_controller.dart'; import 'package:qpay/screens/onboarding/onboarding_controller.dart';
import 'package:qpay/screens/splash/splash_provider.dart';
import 'package:qpay/screens/workspaces/workspace_provider.dart';
import 'package:qpay/providers/uptime_provider.dart';
import 'package:qpay/screens/accounts/account_provider.dart';
import 'package:qpay/theme/app_theme.dart'; import 'package:qpay/theme/app_theme.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
const String testEnv = 'assets/.env.test';
const String liveEnv = 'assets/.env.live';
const String appEnv = String.fromEnvironment('APP_ENV', defaultValue: 'test');
String resolveEnvFile(String env) {
switch (env.toLowerCase()) {
case 'live':
return liveEnv;
case 'test':
default:
return testEnv;
}
}
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
usePathUrlStrategy(); if (kIsWeb) {
usePathUrlStrategy();
}
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
await dotenv.load(fileName: resolveEnvFile(appEnv));
// Read the persisted auth token before the first frame so the router // Read the persisted auth token before the first frame so the router
// knows whether the user is already signed in on cold start. Without // knows whether the user is already signed in on cold start. Without
// this we would briefly treat logged-in users as guests and bounce // this we would briefly treat logged-in users as guests and bounce
@@ -46,6 +36,14 @@ void main() async {
ChangeNotifierProvider<OnboardingController>( ChangeNotifierProvider<OnboardingController>(
create: (_) => OnboardingController(), create: (_) => OnboardingController(),
), ),
ChangeNotifierProvider<SplashProvider>(create: (_) => SplashProvider()),
ChangeNotifierProvider<WorkspaceProvider>(
create: (_) => WorkspaceProvider(),
),
ChangeNotifierProvider<UptimeProvider>(create: (_) => UptimeProvider()),
ChangeNotifierProvider<AccountProvider>(
create: (_) => AccountProvider(),
),
], ],
child: const MyApp(), child: const MyApp(),
), ),

View File

@@ -0,0 +1,39 @@
class UptimeItemModel {
final String id;
final String createdAt;
final String updatedAt;
final bool deleted;
final String name;
final bool status;
const UptimeItemModel({
required this.id,
required this.createdAt,
required this.updatedAt,
required this.deleted,
required this.name,
required this.status,
});
factory UptimeItemModel.fromJson(Map<String, dynamic> json) {
return UptimeItemModel(
id: json['id'] as String,
createdAt: json['createdAt'] as String,
updatedAt: json['updatedAt'],
deleted: json['deleted'] as bool,
name: json['name'] as String,
status: json['status'] as bool,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'createdAt': createdAt,
'updatedAt': updatedAt,
'deleted': deleted,
'name': name,
'status': status,
};
}
}

View File

@@ -0,0 +1,43 @@
class UserModel {
final String token;
final String type;
final String username;
final String email;
final String phone;
final String firstName;
final String uuid;
UserModel({
required this.token,
required this.type,
required this.username,
required this.email,
required this.phone,
required this.firstName,
required this.uuid,
});
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
token: json['token'] as String,
type: json['type'] as String,
username: json['username'] as String,
email: json['email'] as String,
phone: json['phone'] as String,
firstName: json['firstName'] as String,
uuid: json['uuid'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'token': token,
'type': type,
'username': username,
'email': email,
'phone': phone,
'firstName': firstName,
'uuid': uuid,
};
}
}

View File

@@ -15,7 +15,6 @@ class ScaffoldWithNavBar extends StatefulWidget {
} }
class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> { class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
bool _isLoggedIn = false;
late String initials; late String initials;
@override @override
@@ -29,7 +28,6 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
if (prefs.getString("token") != null) { if (prefs.getString("token") != null) {
setState(() { setState(() {
_isLoggedIn = true;
initials = prefs.getString("initials")!; initials = prefs.getString("initials")!;
}); });
} }
@@ -51,13 +49,17 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
Expanded( Expanded(
child: NavigationRail( child: NavigationRail(
extended: true, extended: true,
leading: Padding( leading: Align(
padding: const EdgeInsets.symmetric( alignment: Alignment.centerLeft,
vertical: 12.0, child: Padding(
), padding: const EdgeInsets.symmetric(
child: Image( vertical: 12.0,
image: AssetImage('assets/velocity.png'), horizontal: 8,
width: 150, ),
child: Image(
image: AssetImage('assets/velocity.png'),
width: 150,
),
), ),
), ),
labelType: NavigationRailLabelType.none, labelType: NavigationRailLabelType.none,
@@ -153,6 +155,11 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
"selectedIcon": Icon(Icons.history), "selectedIcon": Icon(Icons.history),
"label": 'History', "label": 'History',
}, },
{
"icon": Icon(Icons.more_horiz),
"selectedIcon": Icon(Icons.more_horiz),
"label": 'More',
},
]; ];
} }
@@ -167,6 +174,9 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
case 2: case 2:
context.go('/history'); context.go('/history');
break; break;
case 3:
context.go('/more');
break;
} }
} }
@@ -174,6 +184,10 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
final String location = GoRouterState.of(context).uri.path; final String location = GoRouterState.of(context).uri.path;
if (location.startsWith('/groups')) return 1; if (location.startsWith('/groups')) return 1;
if (location.startsWith('/history')) return 2; if (location.startsWith('/history')) return 2;
if (location.startsWith('/more') ||
location.startsWith('/users') ||
location.startsWith('/uptime'))
return 3;
return 0; return 0;
} }

View File

@@ -0,0 +1,18 @@
/// Stub implementation of browser APIs for non-web platforms.
class BrowserStorage {
String? getItem(String key) => null;
void setItem(String key, String value) {}
void removeItem(String key) {}
}
/// Stub for window.location
class BrowserLocation {
String get href => '';
set href(String value) {}
}
/// Browser utility class for non-web platforms.
class Browser {
static final BrowserStorage localStorage = BrowserStorage();
static final BrowserLocation location = BrowserLocation();
}

View File

@@ -0,0 +1,22 @@
import 'package:web/web.dart' as web;
/// Web implementation of browser APIs using package:web.
class BrowserStorage {
String? getItem(String key) => web.window.localStorage.getItem(key);
void setItem(String key, String value) =>
web.window.localStorage.setItem(key, value);
void removeItem(String key) =>
web.window.localStorage.removeItem(key);
}
/// Web implementation for window.location
class BrowserLocation {
String get href => web.window.location.href;
set href(String value) => web.window.location.href = value;
}
/// Browser utility class using package:web.
class Browser {
static final BrowserStorage localStorage = BrowserStorage();
static final BrowserLocation location = BrowserLocation();
}

View File

@@ -0,0 +1,3 @@
/// Stub for path URL strategy on non-web platforms.
/// Does nothing since URL strategies are web-only.
void usePathUrlStrategy() {}

View File

@@ -0,0 +1,6 @@
import 'package:flutter_web_plugins/flutter_web_plugins.dart' as flutter_web_plugins;
/// Web implementation that sets the path URL strategy.
void usePathUrlStrategy() {
flutter_web_plugins.usePathUrlStrategy();
}

View File

@@ -0,0 +1,54 @@
import 'package:flutter/foundation.dart';
import 'package:qpay/http/http.dart';
import 'package:qpay/models/uptime_item_model.dart';
enum UptimeStatus { idle, loading, success, error }
class UptimeProvider extends ChangeNotifier {
final Http _http = Http();
UptimeStatus _status = UptimeStatus.idle;
List<UptimeItemModel> _items = [];
String? _error;
UptimeStatus get status => _status;
List<UptimeItemModel> get items => _items;
String? get error => _error;
bool get isLoading => _status == UptimeStatus.loading;
bool get hasError => _status == UptimeStatus.error;
/// Count of services that are currently up (status == true).
int get upCount => _items.where((i) => i.status).length;
/// Count of services that are currently down (status == false).
int get downCount => _items.where((i) => !i.status).length;
Future<void> fetchUptimes() async {
_status = UptimeStatus.loading;
_error = null;
notifyListeners();
try {
final response = await _http.getRaw(
'/public/uptimes?sort=createdAt%2Cdesc',
);
if (response.statusCode == 200) {
final body = response.data as Map<String, dynamic>;
final content = body['content'] as List<dynamic>;
_items = content
.map((e) => UptimeItemModel.fromJson(e as Map<String, dynamic>))
.toList();
_status = UptimeStatus.success;
} else {
_error = 'Failed to load uptime data (status ${response.statusCode})';
_status = UptimeStatus.error;
}
} catch (e) {
_error = 'Network error: ${e.toString()}';
_status = UptimeStatus.error;
}
notifyListeners();
}
}

View File

@@ -6,12 +6,13 @@ import 'package:qpay/screens/confirm/confirm_screen.dart';
import 'package:qpay/screens/gateway/gateway_redirect.dart'; import 'package:qpay/screens/gateway/gateway_redirect.dart';
import 'package:qpay/screens/gateway/gateway_screen.dart'; import 'package:qpay/screens/gateway/gateway_screen.dart';
import 'package:qpay/screens/gateway/gateway_web_screen.dart'; import 'package:qpay/screens/gateway/gateway_web_screen.dart';
import 'package:qpay/screens/groups/batch_create_screen.dart'; import 'package:qpay/screens/groups/screens/batch_create_screen.dart';
import 'package:qpay/screens/groups/batch_detail_screen.dart'; import 'package:qpay/screens/groups/screens/batch_detail_screen.dart';
import 'package:qpay/screens/groups/batch_item_screen.dart'; import 'package:qpay/screens/groups/screens/batch_item_screen.dart';
import 'package:qpay/screens/groups/group_create_screen.dart'; import 'package:qpay/screens/groups/screens/create_group_from_file_screen.dart';
import 'package:qpay/screens/groups/group_detail_screen.dart'; import 'package:qpay/screens/groups/screens/group_create_screen.dart';
import 'package:qpay/screens/groups/groups_screen.dart'; import 'package:qpay/screens/groups/screens/group_detail_screen.dart';
import 'package:qpay/screens/groups/screens/groups_screen.dart';
import 'package:qpay/screens/groups/models/group_batch_model.dart'; import 'package:qpay/screens/groups/models/group_batch_model.dart';
import 'package:qpay/screens/groups/models/recipient_group_model.dart'; import 'package:qpay/screens/groups/models/recipient_group_model.dart';
import 'package:qpay/screens/history/history_screen.dart'; import 'package:qpay/screens/history/history_screen.dart';
@@ -27,7 +28,12 @@ import 'package:qpay/screens/poll/poll_screen.dart';
import 'package:qpay/screens/profile_screen.dart'; import 'package:qpay/screens/profile_screen.dart';
import 'package:qpay/screens/receipt/receipt_screen.dart'; import 'package:qpay/screens/receipt/receipt_screen.dart';
import 'package:qpay/screens/recipient/recipients_screen.dart'; import 'package:qpay/screens/recipient/recipients_screen.dart';
import 'package:qpay/screens/splash_screen.dart'; import 'package:qpay/screens/splash/splash_screen.dart';
import 'package:qpay/screens/workspaces/workspace_screen.dart';
import 'package:qpay/screens/users/screens/user_list_screen.dart';
import 'package:qpay/screens/more/more_screen.dart';
import 'package:qpay/screens/uptime/uptime_status_screen.dart';
import 'package:qpay/screens/contact/contact_screen.dart';
/// Tracks whether the splash animation is complete so that /// Tracks whether the splash animation is complete so that
/// GoRouter's redirect can show the splash first, then release /// GoRouter's redirect can show the splash first, then release
@@ -63,7 +69,7 @@ final SplashState splashState = SplashState();
/// Route prefixes that require the user to be signed in. Any URL /// Route prefixes that require the user to be signed in. Any URL
/// whose path starts with one of these strings will be redirected /// whose path starts with one of these strings will be redirected
/// to the sign-in screen when no auth token is present. /// to the sign-in screen when no auth token is present.
const List<String> _authProtectedRoutePrefixes = ['/groups']; const List<String> _authProtectedRoutePrefixes = ['/groups', '/users'];
bool _isAuthProtected(String path) { bool _isAuthProtected(String path) {
for (final prefix in _authProtectedRoutePrefixes) { for (final prefix in _authProtectedRoutePrefixes) {
@@ -134,9 +140,10 @@ GoRouter buildRouter() {
return authState.requireAuth(path); return authState.requireAuth(path);
} }
// Splash → original route or home. // Splash → workspace selector (which may auto-redirect to home
// if only one workspace exists).
if (path == '/splash') { if (path == '/splash') {
return splashState.intendedRoute ?? '/'; return '/workspace';
} }
} }
@@ -152,12 +159,16 @@ GoRouter buildRouter() {
ShellRoute( ShellRoute(
builder: (context, state, child) { builder: (context, state, child) {
final location = GoRouterState.of(context).uri.toString(); final location = GoRouterState.of(context).uri.toString();
if (location.startsWith('/onboarding/')) { if (location.startsWith('/onboarding/') || location == '/workspace') {
return child; return child;
} }
return ScaffoldWithNavBar(child: child); return ScaffoldWithNavBar(child: child);
}, },
routes: [ routes: [
GoRoute(
path: '/workspace',
builder: (context, state) => const WorkspaceScreen(),
),
GoRoute(path: '/', builder: (context, state) => const HomeScreen()), GoRoute(path: '/', builder: (context, state) => const HomeScreen()),
GoRoute( GoRoute(
path: '/home', path: '/home',
@@ -218,6 +229,22 @@ GoRouter buildRouter() {
path: '/profile', path: '/profile',
builder: (context, state) => const ProfileScreen(), builder: (context, state) => const ProfileScreen(),
), ),
GoRoute(
path: '/more',
builder: (context, state) => const MoreScreen(),
),
GoRoute(
path: '/contact',
builder: (context, state) => const ContactScreen(),
),
GoRoute(
path: '/uptime',
builder: (context, state) => const UptimeStatusScreen(),
),
GoRoute(
path: '/users',
builder: (context, state) => const UserListScreen(),
),
GoRoute( GoRoute(
path: '/groups', path: '/groups',
builder: (context, state) => const GroupsScreen(), builder: (context, state) => const GroupsScreen(),
@@ -226,6 +253,10 @@ GoRouter buildRouter() {
path: '/groups/create', path: '/groups/create',
builder: (context, state) => const GroupCreateScreen(), builder: (context, state) => const GroupCreateScreen(),
), ),
GoRoute(
path: '/groups/create-from-file',
builder: (context, state) => const CreateGroupFromFileScreen(),
),
GoRoute( GoRoute(
path: '/groups/:groupId', path: '/groups/:groupId',
builder: (context, state) { builder: (context, state) {
@@ -241,11 +272,10 @@ GoRouter buildRouter() {
}, },
), ),
GoRoute( GoRoute(
path: '/groups/:groupId/batches/:batchId', path: '/groups/batches/:batchId',
builder: (context, state) { builder: (context, state) {
final batchId = state.pathParameters['batchId']!; final batchId = state.pathParameters['batchId']!;
final groupId = state.pathParameters['groupId']!; return BatchDetailScreen(batchId: batchId);
return BatchDetailScreen(batchId: batchId, groupId: groupId);
}, },
), ),
GoRoute( GoRoute(

View File

@@ -17,24 +17,59 @@ class AccountProvider extends ChangeNotifier {
String? _accountError; String? _accountError;
String? _statementError; String? _statementError;
/// Cached balances keyed by currency code (e.g. 'USD', 'ZWG').
final Map<String, AccountModel?> _balances = {};
AccountModel? get account => _account; AccountModel? get account => _account;
StatementModel? get statement => _statement; StatementModel? get statement => _statement;
bool get isLoadingAccount => _isLoadingAccount; bool get isLoadingAccount => _isLoadingAccount;
bool get isLoadingStatement => _isLoadingStatement; bool get isLoadingStatement => _isLoadingStatement;
String? get accountError => _accountError; String? get accountError => _accountError;
String? get statementError => _statementError; String? get statementError => _statementError;
bool _isDisposed = false;
Future<ApiResponse<AccountModel>> fetchAccount(String phoneNumber) async { /// Returns a cached account balance for [currency], or null if not yet
/// fetched.
AccountModel? balanceForCurrency(String currency) {
return _balances[currency.toUpperCase()];
}
/// Returns the account with name 'City Wallet' for [currency], or null.
AccountModel? cityWalletBalance() {
return _balances.values.firstWhere(
(a) => a?.name == 'City Wallet',
orElse: () => null,
);
}
@override
void notifyListeners() {
if (!_isDisposed) {
super.notifyListeners();
}
}
@override
void dispose() {
_isDisposed = true;
super.dispose();
}
Future<ApiResponse<AccountModel>> fetchAccount(
String? workspaceId,
String currency,
) async {
_isLoadingAccount = true; _isLoadingAccount = true;
_accountError = null; _accountError = null;
notifyListeners(); notifyListeners();
try { try {
Map<String, dynamic> response = await http.get( Map<String, dynamic> response = await http.get(
'/accounts/phone/$phoneNumber', '/accounts/$workspaceId/balance/$currency',
); );
_account = AccountModel.fromJson(response); _account = AccountModel.fromJson(response);
_balances[currency.toUpperCase()] = _account;
_isLoadingAccount = false; _isLoadingAccount = false;
notifyListeners(); notifyListeners();
return ApiResponse.success(_account!); return ApiResponse.success(_account!);
@@ -45,6 +80,7 @@ class AccountProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
return ApiResponse.failure(_accountError!); return ApiResponse.failure(_accountError!);
} catch (e, s) { } catch (e, s) {
logger.e(e);
logger.e(s); logger.e(s);
_isLoadingAccount = false; _isLoadingAccount = false;
_accountError = 'An unexpected error occurred. Please try again.'; _accountError = 'An unexpected error occurred. Please try again.';
@@ -53,8 +89,20 @@ class AccountProvider extends ChangeNotifier {
} }
} }
/// Pre-load multiple currency balances at once. Results are stored in the
/// [_balances] map so any widget can access them later.
Future<void> preloadBalances(
String? workspaceId,
List<String> currencies,
) async {
for (final currency in currencies) {
await fetchAccount(workspaceId, currency);
}
}
Future<ApiResponse<StatementModel>> fetchStatement({ Future<ApiResponse<StatementModel>> fetchStatement({
required String phoneNumber, required String workspaceId,
required String currency,
required String startDate, required String startDate,
required String endDate, required String endDate,
}) async { }) async {
@@ -64,7 +112,7 @@ class AccountProvider extends ChangeNotifier {
try { try {
Map<String, dynamic> response = await http.get( Map<String, dynamic> response = await http.get(
'/accounts/phone/$phoneNumber/statement' '/accounts/$workspaceId/statement/$currency'
'?startDate=$startDate&endDate=$endDate', '?startDate=$startDate&endDate=$endDate',
); );

View File

@@ -1,19 +1,28 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import 'package:qpay/screens/accounts/account_provider.dart'; import 'package:qpay/screens/accounts/account_provider.dart';
import 'package:qpay/screens/accounts/models/account_model.dart'; import 'package:qpay/screens/accounts/models/account_model.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
class AccountBalanceWidget extends StatefulWidget { class AccountBalanceWidget extends StatefulWidget {
const AccountBalanceWidget({super.key}); final String? selectedCurrency;
final ValueChanged<String>? onCurrencySelected;
const AccountBalanceWidget({
super.key,
this.selectedCurrency,
this.onCurrencySelected,
});
@override @override
State<AccountBalanceWidget> createState() => _AccountBalanceWidgetState(); State<AccountBalanceWidget> createState() => _AccountBalanceWidgetState();
} }
class _AccountBalanceWidgetState extends State<AccountBalanceWidget> class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
with SingleTickerProviderStateMixin { bool _isLoggedIn = false;
late AccountProvider _accountProvider; String? _workspaceId;
AccountModel? _usdAccount; AccountModel? _usdAccount;
AccountModel? _zwgAccount; AccountModel? _zwgAccount;
@@ -21,43 +30,60 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget>
bool _isLoadingZwG = false; bool _isLoadingZwG = false;
String? _usdError; String? _usdError;
String? _zwgError; String? _zwgError;
String? _phoneNumber; bool _showAccountError = false;
late AnimationController _pulseController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_pulseController = AnimationController( _loadAuthStateAndBalances();
vsync: this,
duration: const Duration(milliseconds: 800),
);
_accountProvider = AccountProvider();
_loadBalances();
} }
@override Future<void> _loadAuthStateAndBalances() async {
void dispose() {
_pulseController.dispose();
_accountProvider.dispose();
super.dispose();
}
Future<void> _loadBalances() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
_phoneNumber = prefs.getString('phone');
if (_phoneNumber == null || _phoneNumber!.isEmpty) return; if (prefs.getString("token") != null) {
setState(() {
_isLoggedIn = true;
});
}
_workspaceId = prefs.getString('workspaceId');
if (_workspaceId == null || _workspaceId!.isEmpty) {
return;
}
if (!_isLoggedIn) {
return;
}
if (!mounted) return;
final accountProvider = context.read<AccountProvider>();
setState(() { setState(() {
_isLoadingUsd = true; _isLoadingUsd = true;
_isLoadingZwG = true; _isLoadingZwG = true;
}); });
// Try the cached balances first
final cachedUsd = accountProvider.balanceForCurrency('USD');
final cachedZwg = accountProvider.balanceForCurrency('ZWG');
if (cachedUsd != null && cachedZwg != null) {
if (mounted) {
setState(() {
_usdAccount = cachedUsd;
_zwgAccount = cachedZwg;
_isLoadingUsd = false;
_isLoadingZwG = false;
});
}
return;
}
// Fetch USD balance // Fetch USD balance
final usdResult = await _accountProvider.fetchAccount( final usdResult = await accountProvider.fetchAccount(_workspaceId, 'USD');
'USD$_phoneNumber',
);
if (mounted) { if (mounted) {
setState(() { setState(() {
_isLoadingUsd = false; _isLoadingUsd = false;
@@ -71,9 +97,7 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget>
} }
// Fetch ZWG balance // Fetch ZWG balance
final zwgResult = await _accountProvider.fetchAccount( final zwgResult = await accountProvider.fetchAccount(_workspaceId, 'ZWG');
'ZWG$_phoneNumber',
);
if (mounted) { if (mounted) {
setState(() { setState(() {
_isLoadingZwG = false; _isLoadingZwG = false;
@@ -86,8 +110,65 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget>
}); });
} }
// If both fetches failed, mark as not logged in
if (mounted &&
!usdResult.isSuccess &&
!zwgResult.isSuccess &&
_isLoggedIn) {
setState(() {
_showAccountError = true;
});
}
}
Future<void> _refreshBalances() async {
final prefs = await SharedPreferences.getInstance();
_workspaceId = prefs.getString('workspaceId');
if (_workspaceId == null || _workspaceId!.isEmpty) return;
if (!mounted) return;
final accountProvider = context.read<AccountProvider>();
setState(() {
_isLoadingUsd = true;
_isLoadingZwG = true;
});
final usdResult = await accountProvider.fetchAccount(_workspaceId, 'USD');
if (mounted) { if (mounted) {
_pulseController.forward(); setState(() {
_isLoadingUsd = false;
if (usdResult.isSuccess && usdResult.data != null) {
_usdAccount = usdResult.data;
_usdError = null;
} else {
_usdError = usdResult.error;
}
});
}
final zwgResult = await accountProvider.fetchAccount(_workspaceId, 'ZWG');
if (mounted) {
setState(() {
_isLoadingZwG = false;
if (zwgResult.isSuccess && zwgResult.data != null) {
_zwgAccount = zwgResult.data;
_zwgError = null;
} else {
_zwgError = zwgResult.error;
}
});
}
if (mounted &&
!usdResult.isSuccess &&
!zwgResult.isSuccess &&
_isLoggedIn) {
setState(() {
_showAccountError = true;
});
} }
} }
@@ -96,51 +177,85 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget>
final theme = Theme.of(context); final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark; final isDark = theme.brightness == Brightness.dark;
if (_phoneNumber == null || _phoneNumber!.isEmpty) { if (!_isLoggedIn) {
return _buildLoginPrompt(theme, isDark);
}
if (_workspaceId == null || _workspaceId!.isEmpty) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
if (_showAccountError) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18),
decoration: BoxDecoration(
color: theme.colorScheme.error.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: theme.colorScheme.error.withValues(alpha: 0.3),
width: 1,
),
),
child: Row(
children: [
Icon(
Icons.error_outline_rounded,
color: theme.colorScheme.error.withValues(alpha: 0.7),
size: 16,
),
const SizedBox(width: 8),
Expanded(
child: Text(
'Unable to load account balances. Please try refreshing.',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.error.withValues(alpha: 0.7),
),
),
),
],
),
),
);
}
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 16), padding: const EdgeInsets.only(bottom: 12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_buildSectionLabel(
theme,
isDark,
Icons.account_balance_wallet_rounded,
'Account Balances',
),
const SizedBox(height: 12),
Row( Row(
children: [ children: [
Expanded( Expanded(
child: _buildBalanceCard( child: _buildCompactCard(
theme: theme, theme: theme,
isDark: isDark, isDark: isDark,
currencyCode: 'USD', currencyCode: 'USD',
currencyName: 'US Dollar',
flagAsset: 'united-states.png', flagAsset: 'united-states.png',
balance: _usdAccount?.balance, balance: _usdAccount?.balance,
isLoading: _isLoadingUsd, isLoading: _isLoadingUsd,
error: _usdError, error: _usdError,
isSelected: widget.selectedCurrency == 'USD',
gradientColors: [ gradientColors: [
const Color(0xFF0D47A1), const Color(0xFF0D47A1),
const Color(0xFF1976D2), const Color(0xFF1976D2),
], ],
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 10),
Expanded( Expanded(
child: _buildBalanceCard( child: _buildCompactCard(
theme: theme, theme: theme,
isDark: isDark, isDark: isDark,
currencyCode: 'ZWG', currencyCode: 'ZWG',
currencyName: 'Zimbabwe Gold',
flagAsset: 'zimbabwe.png', flagAsset: 'zimbabwe.png',
balance: _zwgAccount?.balance, balance: _zwgAccount?.balance,
isLoading: _isLoadingZwG, isLoading: _isLoadingZwG,
error: _zwgError, error: _zwgError,
isSelected: widget.selectedCurrency == 'ZWG',
gradientColors: [ gradientColors: [
const Color(0xFF1B5E20), const Color(0xFF1B5E20),
const Color(0xFF388E3C), const Color(0xFF388E3C),
@@ -149,42 +264,36 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget>
), ),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 6),
Center( Center(
child: TextButton.icon( child: TextButton.icon(
onPressed: _isLoadingUsd || _isLoadingZwG onPressed: _isLoadingUsd || _isLoadingZwG
? null ? null
: () { : () {
_pulseController.reset(); _refreshBalances();
_loadBalances();
}, },
icon: AnimatedBuilder( icon: Icon(
animation: _pulseController, Icons.refresh_rounded,
builder: (context, child) { size: 14,
return Transform.rotate( color: _isLoadingUsd || _isLoadingZwG
angle: _pulseController.value * 6.2832, ? (isDark ? Colors.white24 : Colors.grey.shade400)
child: Icon( : theme.colorScheme.primary,
Icons.refresh_rounded,
size: 16,
color: _isLoadingUsd || _isLoadingZwG
? (isDark ? Colors.white24 : Colors.grey.shade400)
: theme.colorScheme.primary,
),
);
},
), ),
label: Text( label: Text(
_isLoadingUsd || _isLoadingZwG _isLoadingUsd || _isLoadingZwG ? 'Refreshing...' : 'Refresh',
? 'Refreshing...'
: 'Refresh Balances',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: _isLoadingUsd || _isLoadingZwG color: _isLoadingUsd || _isLoadingZwG
? (isDark ? Colors.white24 : Colors.grey.shade400) ? (isDark ? Colors.white24 : Colors.grey.shade400)
: theme.colorScheme.primary, : theme.colorScheme.primary,
), ),
), ),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
), ),
), ),
], ],
@@ -192,197 +301,224 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget>
); );
} }
Widget _buildBalanceCard({ // ──────────────────────────────────────────────────────────────
// Login Prompt
// ──────────────────────────────────────────────────────────────
Widget _buildLoginPrompt(ThemeData theme, bool isDark) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
theme.colorScheme.primary.withValues(alpha: 0.12),
theme.colorScheme.primary.withValues(alpha: 0.04),
],
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.15),
width: 1,
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Register or Login for more features',
style: TextStyle(
fontSize: 14,
color: isDark ? Colors.white : Colors.black87,
),
),
],
),
),
ElevatedButton(
onPressed: () {
context.push('/onboarding/landing');
},
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
child: const Text(
'Get Started',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
),
],
),
),
);
}
Widget _buildCompactCard({
required ThemeData theme, required ThemeData theme,
required bool isDark, required bool isDark,
required String currencyCode, required String currencyCode,
required String currencyName,
required String flagAsset, required String flagAsset,
double? balance, double? balance,
required bool isLoading, required bool isLoading,
String? error, String? error,
required bool isSelected,
required List<Color> gradientColors, required List<Color> gradientColors,
}) { }) {
return Container( return GestureDetector(
padding: const EdgeInsets.all(16), onTap: () {
decoration: BoxDecoration( widget.onCurrencySelected?.call(currencyCode);
borderRadius: BorderRadius.circular(20), },
gradient: LinearGradient( child: AnimatedContainer(
colors: gradientColors, duration: const Duration(milliseconds: 200),
begin: Alignment.topLeft, padding: const EdgeInsets.all(12),
end: Alignment.bottomRight, decoration: BoxDecoration(
), borderRadius: BorderRadius.circular(16),
boxShadow: [ gradient: LinearGradient(
BoxShadow( colors: gradientColors,
color: gradientColors[0].withValues(alpha: 0.3), begin: Alignment.topLeft,
blurRadius: 12, end: Alignment.bottomRight,
offset: const Offset(0, 4),
), ),
], boxShadow: [
), BoxShadow(
child: Column( color: gradientColors[0].withValues(
crossAxisAlignment: CrossAxisAlignment.start, alpha: isSelected ? 0.5 : 0.25,
children: [ ),
// Top row: flag + currency code blurRadius: isSelected ? 12 : 6,
Row( offset: Offset(0, isSelected ? 4 : 2),
mainAxisAlignment: MainAxisAlignment.spaceBetween, ),
children: [ ],
Row( border: isSelected
mainAxisSize: MainAxisSize.min, ? Border.all(color: Colors.white.withValues(alpha: 0.8), width: 2)
children: [ : Border.all(
Container( color: Colors.white.withValues(alpha: 0.15),
width: 24, width: 1,
height: 24, ),
decoration: BoxDecoration( ),
shape: BoxShape.circle, child: Column(
border: Border.all( crossAxisAlignment: CrossAxisAlignment.start,
color: Colors.white.withValues(alpha: 0.3), mainAxisSize: MainAxisSize.min,
width: 1.5, children: [
), // Top row: flag + currency code
Row(
children: [
Container(
width: 22,
height: 22,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Colors.white.withValues(alpha: 0.3),
width: 1.5,
), ),
child: ClipOval( ),
child: Image.asset( child: ClipOval(
flagAsset, child: Image.asset(
fit: BoxFit.cover, 'assets/$flagAsset',
errorBuilder: (_, __, ___) => Icon( fit: BoxFit.cover,
Icons.monetization_on_outlined, errorBuilder: (_, __, ___) => Icon(
size: 14, Icons.monetization_on_outlined,
color: Colors.white.withValues(alpha: 0.8), size: 12,
), color: Colors.white.withValues(alpha: 0.8),
), ),
), ),
), ),
const SizedBox(width: 8), ),
Text( const SizedBox(width: 6),
Expanded(
child: Text(
currencyCode, currencyCode,
style: const TextStyle( style: const TextStyle(
fontSize: 13, fontSize: 12,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: Colors.white, color: Colors.white,
letterSpacing: 0.5, letterSpacing: 0.5,
), ),
), ),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
), ),
child: Text( if (isSelected)
currencyName, Container(
style: TextStyle( width: 18,
fontSize: 8, height: 18,
fontWeight: FontWeight.w500, decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.9), color: Colors.white.withValues(alpha: 0.3),
shape: BoxShape.circle,
),
child: const Icon(
Icons.check_rounded,
size: 12,
color: Colors.white,
),
), ),
],
),
const SizedBox(height: 10),
// Balance amount
if (isLoading)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 70,
height: 12,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(4),
),
),
],
)
else if (error != null)
Row(
children: [
Icon(
Icons.error_outline_rounded,
color: Colors.white.withValues(alpha: 0.7),
size: 16,
),
const SizedBox(width: 4),
Text(
'Unavailable',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: 0.7),
),
),
],
)
else ...[
Text(
_formatBalance(balance ?? 0.0),
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: 0.5,
height: 1.1,
), ),
), ),
], ],
),
const SizedBox(height: 16),
// Balance amount
if (isLoading)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 80,
height: 14,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 4),
Container(
width: 50,
height: 10,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
),
],
)
else if (error != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.error_outline_rounded,
color: Colors.white.withValues(alpha: 0.7),
size: 20,
),
const SizedBox(height: 4),
Text(
'Unavailable',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: 0.7),
),
),
],
)
else ...[
Text(
_formatBalance(balance ?? 0.0),
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: 0.5,
height: 1.1,
),
),
const SizedBox(height: 4),
Text(
currencyCode == 'USD' ? 'Available Balance' : 'Available Balance',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w400,
color: Colors.white.withValues(alpha: 0.7),
),
),
], ],
], ),
), ),
); );
} }
Widget _buildSectionLabel(
ThemeData theme,
bool isDark,
IconData icon,
String label,
) {
return Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 16, color: theme.colorScheme.primary),
),
const SizedBox(width: 10),
Text(
label,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
letterSpacing: 0.3,
),
),
],
);
}
String _formatBalance(double balance) { String _formatBalance(double balance) {
final formatter = NumberFormat('#,##0.00', 'en_US'); final formatter = NumberFormat('#,##0.00', 'en_US');
return '\$${formatter.format(balance)}'; return '\$${formatter.format(balance)}';

View File

@@ -1,7 +1,9 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/accounts/account_provider.dart';
import 'package:qpay/screens/confirm/confirm_controller.dart'; import 'package:qpay/screens/confirm/confirm_controller.dart';
import 'package:qpay/widgets/app_snack_bar.dart'; import 'package:qpay/widgets/app_snack_bar.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
@@ -370,6 +372,15 @@ class _ConfirmScreenState extends State<ConfirmScreen>
children: [ children: [
_buildDetailRow( _buildDetailRow(
icon: Icons.monetization_on_outlined, icon: Icons.monetization_on_outlined,
label: "Currency",
value: controller
.transactionController
.model
.confirmationData["debitCurrency"]
.toString(),
),
_buildDetailRow(
icon: Icons.money,
label: "Amount", label: "Amount",
value: controller value: controller
.transactionController .transactionController
@@ -591,42 +602,112 @@ class _ConfirmScreenState extends State<ConfirmScreen>
onTap: () async { onTap: () async {
_handlePayment(); _handlePayment();
}, },
child: ListTile( child: Padding(
leading: Container( padding: const EdgeInsets.all(12),
padding: const EdgeInsets.all(8), child: Column(
decoration: BoxDecoration( children: [
color: Theme.of( ListTile(
context, contentPadding: EdgeInsets.zero,
).colorScheme.primary.withValues(alpha: 0.1), leading: Container(
shape: BoxShape.circle, padding: const EdgeInsets.all(8),
), decoration: BoxDecoration(
child: Image( color: Theme.of(
image: AssetImage( context,
"assets/${controller.transactionController.model.selectedPaymentProcessor.image}", ).colorScheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Image(
image: AssetImage(
"assets/${controller.transactionController.model.selectedPaymentProcessor.image}",
),
),
),
title: Text(
controller
.transactionController
.model
.selectedPaymentProcessor
.name,
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
controller
.transactionController
.model
.selectedPaymentProcessor
.description,
style: TextStyle(
fontWeight: FontWeight.normal,
fontSize: 10,
),
),
trailing: Icon(
Icons.chevron_right,
color: Theme.of(
context,
).colorScheme.secondary.withValues(alpha: 0.7),
),
), ),
), Builder(
), builder: (context) {
title: Text( final accountProvider = context.watch<AccountProvider>();
controller final currency = controller
.transactionController .transactionController
.model .model
.selectedPaymentProcessor .confirmationData["debitCurrency"]
.name, ?.toString();
style: TextStyle(fontWeight: FontWeight.bold), final cityWallet = currency != null
), ? accountProvider.balanceForCurrency(currency)
subtitle: Text( : null;
controller final hasCityWallet = cityWallet != null;
.transactionController if (!hasCityWallet) return const SizedBox.shrink();
.model
.selectedPaymentProcessor if (controller
.description, .transactionController
style: TextStyle(fontWeight: FontWeight.normal, fontSize: 10), .model
), .selectedPaymentProcessor
trailing: Icon( .label !=
Icons.chevron_right, 'WALLET') {
color: Theme.of( return const SizedBox.shrink();
context, }
).colorScheme.secondary.withValues(alpha: 0.7),
return Padding(
padding: const EdgeInsets.only(top: 4, bottom: 4),
child: Row(
children: [
Icon(
Icons.account_balance_wallet_outlined,
size: 14,
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.7),
),
const SizedBox(width: 6),
Text(
'City Wallet Balance',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.7),
),
),
const Spacer(),
Text(
'\$${cityWallet!.balance.toStringAsFixed(2)}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.primary,
),
),
],
),
);
},
),
],
), ),
), ),
), ),

View File

@@ -0,0 +1,303 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:url_launcher/url_launcher.dart';
class ContactScreen extends StatelessWidget {
const ContactScreen({super.key});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDark
? const Color(0xFF0D0D0D)
: const Color(0xFFF8F9FA),
appBar: AppBar(
leading: context.canPop()
? IconButton(
onPressed: () => context.pop(),
icon: const Icon(Icons.arrow_back_rounded),
color: theme.colorScheme.primary,
)
: const SizedBox.shrink(),
title: Text(
'Contact Us',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 20,
color: isDark ? Colors.white : Colors.black87,
),
),
centerTitle: true,
surfaceTintColor: Colors.transparent,
backgroundColor: isDark
? const Color(0xFF0D0D0D)
: const Color(0xFFF8F9FA),
),
body: Center(
child: LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
? ResponsivePolicy.md.toDouble()
: constraints.maxWidth;
return SizedBox(
width: maxWidth,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
// Header card
_buildHeaderCard(theme, isDark),
const SizedBox(height: 16),
// Social links card
_buildSocialLinksCard(theme, isDark),
],
),
);
},
),
),
);
}
Widget _buildHeaderCard(ThemeData theme, bool isDark) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 24),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade200,
width: 1,
),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.3)
: Colors.black.withValues(alpha: 0.04),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Column(
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
theme.colorScheme.primary.withValues(alpha: 0.2),
theme.colorScheme.primary.withValues(alpha: 0.05),
],
),
shape: BoxShape.circle,
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.15),
width: 1.5,
),
),
child: Icon(
Icons.support_agent_rounded,
size: 36,
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 20),
Text(
'We\'d love to hear from you',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 8),
Text(
'Reach out to us through any of the channels below.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: isDark ? Colors.white60 : Colors.grey.shade600,
height: 1.5,
),
),
],
),
);
}
Widget _buildSocialLinksCard(ThemeData theme, bool isDark) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade200,
width: 1,
),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.3)
: Colors.black.withValues(alpha: 0.04),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Text(
'Connect With Us',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white54 : Colors.grey.shade500,
letterSpacing: 1,
),
),
),
const SizedBox(height: 4),
// Instagram
_buildSocialTile(
theme: theme,
isDark: isDark,
icon: Icons.camera_alt_rounded,
iconBackgroundColor: const Color(0xFFE1306C),
title: 'Instagram',
subtitle: 'Follow us for updates',
onTap: () {
launchUrl(
Uri.parse('https://www.instagram.com/velocityafrica/'),
mode: LaunchMode.externalApplication,
);
},
),
const SizedBox(height: 8),
// WhatsApp
_buildSocialTile(
theme: theme,
isDark: isDark,
icon: Icons.chat_rounded,
iconBackgroundColor: const Color(0xFF25D366),
title: 'WhatsApp',
subtitle: 'Chat with us directly',
onTap: () {
launchUrl(
Uri.parse('https://wa.me/263787770295'),
mode: LaunchMode.externalApplication,
);
},
),
],
),
);
}
Widget _buildSocialTile({
required ThemeData theme,
required bool isDark,
required IconData icon,
required Color iconBackgroundColor,
required String title,
required String subtitle,
required VoidCallback onTap,
}) {
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.03)
: Colors.grey.shade50,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.04)
: Colors.grey.shade100,
width: 1,
),
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: iconBackgroundColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
),
child: Center(
child: Icon(icon, color: iconBackgroundColor, size: 24),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w400,
color: isDark ? Colors.white54 : Colors.grey.shade600,
),
),
],
),
),
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: iconBackgroundColor.withValues(alpha: 0.1),
),
child: const Icon(
Icons.arrow_forward_rounded,
size: 16,
color: Colors.grey,
),
),
],
),
),
),
);
}
}

View File

@@ -57,7 +57,7 @@ class GatewayController extends ChangeNotifier {
dynamic response = workflowResponse['body']; dynamic response = workflowResponse['body'];
model.status = response['status']; model.status = response['pollingStatus'];
transactionController.model.paymentStatus = response['paymentStatus']; transactionController.model.paymentStatus = response['paymentStatus'];
finishLoading(); finishLoading();
@@ -83,19 +83,13 @@ class GatewayController extends ChangeNotifier {
} }
Future<ApiResponse<Map<String, dynamic>>> pollTransaction(String uid) async { Future<ApiResponse<Map<String, dynamic>>> pollTransaction(String uid) async {
// poll up to 30 times (~5 minutes at 10-second intervals) // poll up to 15 times (~45 seconds at 3-second intervals)
int maxAttempts = 15; int maxAttempts = 15;
int attempt = 0; int attempt = 0;
while (!model.isCancelled && attempt < maxAttempts) { while (!model.isCancelled && attempt < maxAttempts) {
attempt++; attempt++;
// Wait 10 seconds before each poll request
await Future.delayed(const Duration(seconds: 10));
// Check again after delay in case it was cancelled during the delay
if (model.isCancelled) break;
ApiResponse<Map<String, dynamic>> result = await poll(uid); ApiResponse<Map<String, dynamic>> result = await poll(uid);
// Only stop on success or failure // Only stop on success or failure
@@ -105,8 +99,15 @@ class GatewayController extends ChangeNotifier {
if (status == 'SUCCESS') { if (status == 'SUCCESS') {
return result; return result;
} }
await Future.delayed(const Duration(seconds: 3));
if (model.isCancelled) break;
// Otherwise (e.g. PENDING), continue polling // Otherwise (e.g. PENDING), continue polling
} else { } else {
await Future.delayed(const Duration(seconds: 3));
if (model.isCancelled) break;
// Network error — show failure UI and stop // Network error — show failure UI and stop
model.isCancelled = true; model.isCancelled = true;
model.status = 'FAILED'; model.status = 'FAILED';

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:qpay/config/app_config.dart';
import 'package:qpay/screens/gateway/gateway_controller.dart'; import 'package:qpay/screens/gateway/gateway_controller.dart';
import 'package:qpay/widgets/app_snack_bar.dart'; import 'package:qpay/widgets/app_snack_bar.dart';
import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter/webview_flutter.dart';
@@ -56,7 +56,7 @@ class _GatewayScreenState extends State<GatewayScreen> {
) )
..loadRequest(Uri.parse(url)); ..loadRequest(Uri.parse(url));
bool simulate = bool.parse(dotenv.env['SIMULATE_PAYMENT_SUCCESS']!); bool simulate = AppConfig.simulatePaymentSuccess;
if (simulate) { if (simulate) {
await Future.delayed(Duration(seconds: 10)); await Future.delayed(Duration(seconds: 10));
String targetUrl = url.replaceAll("/pay/", "/receipt/"); String targetUrl = url.replaceAll("/pay/", "/receipt/");

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:qpay/config/app_config.dart';
import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/gateway/gateway_controller.dart'; import 'package:qpay/screens/gateway/gateway_controller.dart';
import 'package:web/web.dart' as web; import 'package:qpay/browser.dart';
class GatewayWebScreen extends StatefulWidget { class GatewayWebScreen extends StatefulWidget {
const GatewayWebScreen({super.key}); const GatewayWebScreen({super.key});
@@ -34,9 +34,7 @@ class _GatewayWebScreenState extends State<GatewayWebScreen> {
redirecting = true; redirecting = true;
}); });
bool simulate = bool.parse( bool simulate = AppConfig.simulatePaymentSuccess;
dotenv.env['SIMULATE_PAYMENT_SUCCESS'] ?? 'false',
);
if (simulate) { if (simulate) {
// In simulation mode, just go to polling // In simulation mode, just go to polling
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -50,7 +48,7 @@ class _GatewayWebScreenState extends State<GatewayWebScreen> {
final transactionId = final transactionId =
controller.transactionController.model.confirmationData['id']; controller.transactionController.model.confirmationData['id'];
if (transactionId != null) { if (transactionId != null) {
web.window.localStorage.setItem( Browser.localStorage.setItem(
'pendingTransactionId', 'pendingTransactionId',
transactionId.toString(), transactionId.toString(),
); );
@@ -60,7 +58,7 @@ class _GatewayWebScreenState extends State<GatewayWebScreen> {
// After payment, the gateway will redirect back to this app's host. // After payment, the gateway will redirect back to this app's host.
// index.html then reads the saved transaction ID and navigates to // index.html then reads the saved transaction ID and navigates to
// /poll/:id so the app can immediately start polling for the result. // /poll/:id so the app can immediately start polling for the result.
web.window.location.href = url; Browser.location.href = url;
} }
@override @override

View File

@@ -1,439 +0,0 @@
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:qpay/widgets/app_snack_bar.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) {
AppSnackBar.show(context, 'Please select a bill provider.');
return;
}
final amount = double.tryParse(_amountController.text.trim());
if (amount == null || amount <= 0) {
AppSnackBar.show(context, '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 {
AppSnackBar.showError(context, 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,
),
),
),
],
),
),
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,6 +7,8 @@ import 'package:qpay/screens/home/home_controller.dart';
import 'package:qpay/screens/pay/pay_controller.dart'; import 'package:qpay/screens/pay/pay_controller.dart';
import 'package:qpay/screens/transactions/transaction_model.dart'; import 'package:qpay/screens/transactions/transaction_model.dart';
import '../models/batch_item_update_model.dart';
class BatchScreenModel { class BatchScreenModel {
List<GroupBatch> batches = []; List<GroupBatch> batches = [];
List<GroupBatchItem> batchItems = []; List<GroupBatchItem> batchItems = [];
@@ -22,11 +24,26 @@ class BatchScreenModel {
int currentPage = 0; int currentPage = 0;
int totalPages = 0; int totalPages = 0;
int totalElements = 0; int totalElements = 0;
int pageSize = 10;
String searchQuery = ''; String searchQuery = '';
String? statusFilter; String? statusFilter;
String? currencyFilter; String? currencyFilter;
bool selectMode = false; bool selectMode = false;
Set<String> selectedBatchIds = {}; Set<String> selectedBatchIds = {};
// Batch items pagination state
int batchItemsCurrentPage = 0;
int batchItemsTotalPages = 0;
int batchItemsTotalElements = 0;
int batchItemsPageSize = 10;
String batchItemsSearchQuery = '';
String? batchItemsStatusFilter;
String? batchItemsRecipientAccount;
bool batchItemsIsLoadingMore = false;
// Provider products cache: providerId -> list of products
Map<String, List<BillProduct>> providerProducts = {};
Set<String> loadingProviderProducts = {};
} }
class BatchController extends ChangeNotifier { class BatchController extends ChangeNotifier {
@@ -62,10 +79,10 @@ class BatchController extends ChangeNotifier {
return query.substring(0, query.length - 1); return query.substring(0, query.length - 1);
} }
Future<ApiResponse<List<BillProvider>>> loadProviders() async { Future<ApiResponse<List<BillProvider>>> loadProviders(String currency) async {
try { try {
final raw = await http.get( final raw = await http.get(
'/public/providers?currency=USD&sort=priority,asc', '/public/providers?currency=$currency&sort=priority,asc',
); );
final response = _unwrap(raw); final response = _unwrap(raw);
final List<dynamic> content; final List<dynamic> content;
@@ -90,9 +107,11 @@ class BatchController extends ChangeNotifier {
} }
} }
Future<ApiResponse<List<PaymentProcessor>>> loadProcessors() async { Future<ApiResponse<List<PaymentProcessor>>> loadProcessors(currency) async {
try { try {
final raw = await http.get('/public/payment-processors'); final raw = await http.get(
'/public/payment-processors?currency=$currency',
);
final response = _unwrap(raw); final response = _unwrap(raw);
final List<dynamic> content = response is List final List<dynamic> content = response is List
? response ? response
@@ -111,10 +130,46 @@ class BatchController extends ChangeNotifier {
} }
} }
Future<ApiResponse<List<BillProduct>>> loadProviderProducts(
String providerId,
) async {
if (model.loadingProviderProducts.contains(providerId)) {
// Already loading, return cached data if available
final cached = model.providerProducts[providerId];
if (cached != null) {
return ApiResponse.success(cached);
}
return ApiResponse.failure('Loading in progress');
}
model.loadingProviderProducts.add(providerId);
if (!_disposed) notifyListeners();
try {
final raw = await http.get('/public/providers/$providerId/products');
final response = _unwrap(raw);
final List<dynamic> list = response is List
? response
: (response['content'] ?? []);
final products = list
.map((e) => BillProduct.fromJson(e as Map<String, dynamic>))
.toList();
model.providerProducts[providerId] = products;
model.loadingProviderProducts.remove(providerId);
if (!_disposed) notifyListeners();
return ApiResponse.success(products);
} catch (e) {
logger.e(e);
model.loadingProviderProducts.remove(providerId);
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to load products');
}
}
Future<ApiResponse<List<GroupBatch>>> listBatches( Future<ApiResponse<List<GroupBatch>>> listBatches(
String groupId, { String groupId, {
int page = 0, int page = 0,
int size = 20, int size = 10,
String? search, String? search,
String? status, String? status,
String? currency, String? currency,
@@ -146,7 +201,7 @@ class BatchController extends ChangeNotifier {
} }
final raw = await http.get( final raw = await http.get(
'/public/group-batches?${buildQueryParameters(params)}', '/group-batches?${buildQueryParameters(params)}',
); );
final response = _unwrap(raw); final response = _unwrap(raw);
final pageableModel = PageableModel.fromJson( final pageableModel = PageableModel.fromJson(
@@ -195,6 +250,7 @@ class BatchController extends ChangeNotifier {
await listBatches( await listBatches(
groupId, groupId,
page: 0, page: 0,
size: model.pageSize,
search: search, search: search,
status: status, status: status,
currency: currency, currency: currency,
@@ -208,6 +264,43 @@ class BatchController extends ChangeNotifier {
await listBatches( await listBatches(
groupId, groupId,
page: model.currentPage + 1, page: model.currentPage + 1,
size: model.pageSize,
search: model.searchQuery.isNotEmpty ? model.searchQuery : null,
status: model.statusFilter,
currency: model.currencyFilter,
);
}
/// Navigates to a specific page of batches (0-based index).
/// Clears the current list and loads the requested page.
Future<void> goToPage(String groupId, int page) async {
if (model.isLoadingMore) return;
if (page < 0 || page >= model.totalPages) return;
if (page == model.currentPage) return;
// Clear existing batches so listBatches replaces instead of appending
model.batches = [];
model.currentPage = 0;
if (!_disposed) notifyListeners();
await listBatches(
groupId,
page: page,
size: model.pageSize,
search: model.searchQuery.isNotEmpty ? model.searchQuery : null,
status: model.statusFilter,
currency: model.currencyFilter,
);
}
/// Changes the page size for batches and reloads from page 0.
Future<void> setPageSize(String groupId, int size) async {
if (size == model.pageSize) return;
model.pageSize = size;
if (!_disposed) notifyListeners();
// Reload from page 0 with the new size
await searchBatches(
groupId,
search: model.searchQuery.isNotEmpty ? model.searchQuery : null, search: model.searchQuery.isNotEmpty ? model.searchQuery : null,
status: model.statusFilter, status: model.statusFilter,
currency: model.currencyFilter, currency: model.currencyFilter,
@@ -215,50 +308,170 @@ class BatchController extends ChangeNotifier {
} }
Future<ApiResponse<List<GroupBatchItem>>> listBatchItems( Future<ApiResponse<List<GroupBatchItem>>> listBatchItems(
String batchId, String batchId, {
) async { int page = 0,
model.isLoading = true; int size = 10,
Map<String, String>? filters,
}) async {
if (page == 0) {
model.isLoading = true;
model.batchItems = [];
model.batchItemsCurrentPage = 0;
} else {
model.batchItemsIsLoadingMore = true;
}
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
try { try {
final params = <String, String>{
'groupBatchId': batchId,
'page': page.toString(),
'size': size.toString(),
'sort': 'createdAt,desc',
};
if (filters != null) {
params.addAll(filters);
}
final raw = await http.get( final raw = await http.get(
'/public/group-batches/items?groupBatchId=$batchId&sort=createdAt,desc', '/group-batches/items?${buildQueryParameters(params)}',
); );
final response = _unwrap(raw); final response = _unwrap(raw);
final List<dynamic> content; final pageableModel = PageableModel.fromJson(
if (response is Map && response.containsKey('content')) { response as Map<String, dynamic>,
content = PageableModel.fromJson( );
response as Map<String, dynamic>,
).content; final newItems = pageableModel.content
} else if (response is List) {
content = response;
} else {
content = [];
}
model.batchItems = content
.map((e) => GroupBatchItem.fromJson(e as Map<String, dynamic>)) .map((e) => GroupBatchItem.fromJson(e as Map<String, dynamic>))
.toList(); .toList();
if (page == 0) {
model.batchItems = newItems;
} else {
model.batchItems.addAll(newItems);
}
model.batchItemsCurrentPage = pageableModel.number;
model.batchItemsTotalPages = pageableModel.totalPages;
model.batchItemsTotalElements = pageableModel.totalElements;
model.isLoading = false; model.isLoading = false;
model.batchItemsIsLoadingMore = false;
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
return ApiResponse.success(model.batchItems); return ApiResponse.success(model.batchItems);
} catch (e) { } catch (e) {
logger.e(e); logger.e(e);
model.errorMessage = e.toString(); model.errorMessage = e.toString();
model.batchItems = []; if (page == 0) {
model.batchItems = [];
}
model.isLoading = false; model.isLoading = false;
model.batchItemsIsLoadingMore = false;
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to load batch items'); return ApiResponse.failure('Failed to load batch items');
} }
} }
Future<void> searchBatchItems(
String batchId, {
String? search,
String? status,
String? account,
}) async {
model.batchItemsSearchQuery = search ?? '';
model.batchItemsStatusFilter = status;
model.batchItemsRecipientAccount = account;
await listBatchItems(
batchId,
page: 0,
size: model.batchItemsPageSize,
filters: _buildBatchItemFilters(
search: search,
status: status,
account: account,
),
);
}
Future<void> loadNextBatchItemsPage(String batchId) async {
if (model.batchItemsIsLoadingMore ||
model.batchItemsCurrentPage + 1 >= model.batchItemsTotalPages) {
return;
}
await listBatchItems(
batchId,
page: model.batchItemsCurrentPage + 1,
size: model.batchItemsPageSize,
filters: _buildBatchItemFilters(
search: model.batchItemsSearchQuery.isNotEmpty
? model.batchItemsSearchQuery
: null,
status: model.batchItemsStatusFilter,
account: model.batchItemsRecipientAccount,
),
);
}
/// Navigates to a specific page of batch items (0-based index).
/// Clears the current list and loads the requested page.
Future<void> goToBatchItemsPage(String batchId, int page) async {
if (model.batchItemsIsLoadingMore) return;
if (page < 0 || page >= model.batchItemsTotalPages) return;
if (page == model.batchItemsCurrentPage) return;
// Clear existing items so listBatchItems replaces instead of appending
model.batchItems = [];
model.batchItemsCurrentPage = 0;
if (!_disposed) notifyListeners();
await listBatchItems(
batchId,
page: page,
size: model.batchItemsPageSize,
filters: _buildBatchItemFilters(
search: model.batchItemsSearchQuery.isNotEmpty
? model.batchItemsSearchQuery
: null,
status: model.batchItemsStatusFilter,
account: model.batchItemsRecipientAccount,
),
);
}
/// Changes the page size for batch items and reloads from page 0.
void setBatchItemsPageSize(int size) {
if (size == model.batchItemsPageSize) return;
model.batchItemsPageSize = size;
if (!_disposed) notifyListeners();
}
/// Builds the filters map for batch items queries from individual params.
Map<String, String>? _buildBatchItemFilters({
String? search,
String? status,
String? account,
}) {
final filters = <String, String>{};
if (search != null && search.isNotEmpty) {
filters['recipientName'] = search;
}
if (status != null && status.isNotEmpty) {
filters['status'] = status;
}
if (account != null && account.isNotEmpty) {
filters['recipientAccount'] = account;
}
return filters.isEmpty ? null : filters;
}
Future<ApiResponse<GroupBatch>> getBatch( Future<ApiResponse<GroupBatch>> getBatch(
String batchId, String batchId,
String userId, String workspaceId,
) async { ) async {
model.isActing = true; model.isActing = true;
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
try { try {
final raw = await http.get( final raw = await http.get(
'/public/group-batches/$batchId?userId=$userId', '/group-batches/$batchId?workspaceId=$workspaceId',
); );
final response = _unwrap(raw); final response = _unwrap(raw);
final batch = GroupBatch.fromJson(response as Map<String, dynamic>); final batch = GroupBatch.fromJson(response as Map<String, dynamic>);
@@ -278,7 +491,7 @@ class BatchController extends ChangeNotifier {
model.isActing = true; model.isActing = true;
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
try { try {
final raw = await http.post('/public/group-batches', req.toJson()); final raw = await http.post('/group-batches', req.toJson());
final response = _unwrap(raw); final response = _unwrap(raw);
final batch = GroupBatch.fromJson(response as Map<String, dynamic>); final batch = GroupBatch.fromJson(response as Map<String, dynamic>);
model.currentBatch = batch; model.currentBatch = batch;
@@ -301,7 +514,7 @@ class BatchController extends ChangeNotifier {
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
try { try {
final raw = await http.put( final raw = await http.put(
'/public/group-batches/${request.id}', '/group-batches/${request.id}',
request.toJson(), request.toJson(),
); );
final response = _unwrap(raw); final response = _unwrap(raw);
@@ -324,18 +537,52 @@ class BatchController extends ChangeNotifier {
} }
} }
Future<ApiResponse<List<GroupBatchItem>>> updateBatchItems(
String batchId,
GroupBatchItemUpdateList updateList,
) async {
model.isActing = true;
if (!_disposed) notifyListeners();
try {
final raw = await http.put(
'/group-batches/$batchId/items',
updateList.toJson(),
);
final response = _unwrap(raw);
final items = (response as List<dynamic>)
.map((e) => GroupBatchItem.fromJson(e as Map<String, dynamic>))
.toList();
// Update items in the local batch items list if the current batch matches
model.batchItems = items;
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(items);
} catch (e, s) {
logger.e(e);
logger.e(s);
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to update batch items');
}
}
Future<ApiResponse<GroupBatch>> confirmBatch( Future<ApiResponse<GroupBatch>> confirmBatch(
String batchId, String batchId,
String userId, String workspaceId,
String authType,
) async { ) async {
model.isActing = true; model.isActing = true;
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
try { try {
final key = final key =
'batch-$batchId-confirm-${DateTime.now().millisecondsSinceEpoch}'; 'batch-$batchId-confirm-${DateTime.now().millisecondsSinceEpoch}';
final body = BatchActionRequest(userId: userId, idempotencyKey: key); final body = BatchActionRequest(
workspaceId: workspaceId,
authType: authType,
idempotencyKey: key,
);
final raw = await http.post( final raw = await http.post(
'/public/group-batches/$batchId/confirm', '/group-batches/$batchId/confirm',
body.toJson(), body.toJson(),
); );
final response = _unwrap(raw); final response = _unwrap(raw);
@@ -354,16 +601,19 @@ class BatchController extends ChangeNotifier {
Future<ApiResponse<TransactionModel>> requestBatch( Future<ApiResponse<TransactionModel>> requestBatch(
String batchId, String batchId,
String userId, String workspaceId,
) async { ) async {
model.isActing = true; model.isActing = true;
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
try { try {
final key = final key =
'batch-$batchId-request-${DateTime.now().millisecondsSinceEpoch}'; 'batch-$batchId-request-${DateTime.now().millisecondsSinceEpoch}';
final body = BatchActionRequest(userId: userId, idempotencyKey: key); final body = BatchActionRequest(
workspaceId: workspaceId,
idempotencyKey: key,
);
final raw = await http.post( final raw = await http.post(
'/public/group-batches/$batchId/request', '/group-batches/$batchId/request',
body.toJson(), body.toJson(),
); );
final response = _unwrap(raw); final response = _unwrap(raw);
@@ -382,13 +632,13 @@ class BatchController extends ChangeNotifier {
Future<ApiResponse<Map<String, dynamic>>> downloadBatchReport( Future<ApiResponse<Map<String, dynamic>>> downloadBatchReport(
String batchId, String batchId,
String userId, String workspaceId,
) async { ) async {
model.isActing = true; model.isActing = true;
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
try { try {
final raw = await http.get( final raw = await http.get(
'/public/group-batches/$batchId/report?userId=$userId', '/group-batches/$batchId/report?workspaceId=$workspaceId',
); );
final response = _unwrap(raw); final response = _unwrap(raw);
model.isActing = false; model.isActing = false;
@@ -405,16 +655,19 @@ class BatchController extends ChangeNotifier {
Future<ApiResponse<TransactionModel>> pollBatch( Future<ApiResponse<TransactionModel>> pollBatch(
String batchId, String batchId,
String userId, String workspaceId,
) async { ) async {
model.isActing = true; model.isActing = true;
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
try { try {
final key = final key =
'batch-$batchId-poll-${DateTime.now().millisecondsSinceEpoch}'; 'batch-$batchId-poll-${DateTime.now().millisecondsSinceEpoch}';
final body = BatchActionRequest(userId: userId, idempotencyKey: key); final body = BatchActionRequest(
workspaceId: workspaceId,
idempotencyKey: key,
);
final raw = await http.post( final raw = await http.post(
'/public/group-batches/$batchId/poll', '/group-batches/$batchId/poll',
body.toJson(), body.toJson(),
); );
final response = _unwrap(raw); final response = _unwrap(raw);
@@ -423,7 +676,7 @@ class BatchController extends ChangeNotifier {
); );
model.isActing = false; model.isActing = false;
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
if (response['status'] != 'FAILED') { if (batchTransaction.pollingStatus == 'SUCCESS') {
return ApiResponse.success(batchTransaction); return ApiResponse.success(batchTransaction);
} else { } else {
return ApiResponse.failure( return ApiResponse.failure(
@@ -441,10 +694,10 @@ class BatchController extends ChangeNotifier {
Future<void> deleteBatch({ Future<void> deleteBatch({
required String batchId, required String batchId,
required String userId, required String workspaceId,
}) async { }) async {
try { try {
await http.delete('/public/group-batches/$batchId?userId=$userId'); await http.delete('/group-batches/$batchId?workspaceId=$workspaceId');
model.batches.removeWhere((b) => b.id == batchId); model.batches.removeWhere((b) => b.id == batchId);
model.selectedBatchIds.remove(batchId); model.selectedBatchIds.remove(batchId);
model.currentBatch = null; model.currentBatch = null;
@@ -458,7 +711,7 @@ class BatchController extends ChangeNotifier {
Future<void> deleteSelectedBatches({ Future<void> deleteSelectedBatches({
required String groupId, required String groupId,
required String userId, required String workspaceId,
}) async { }) async {
final idsToDelete = List<String>.from(model.selectedBatchIds); final idsToDelete = List<String>.from(model.selectedBatchIds);
model.selectMode = false; model.selectMode = false;
@@ -466,7 +719,7 @@ class BatchController extends ChangeNotifier {
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
for (final id in idsToDelete) { for (final id in idsToDelete) {
await deleteBatch(batchId: id, userId: userId); await deleteBatch(batchId: id, workspaceId: workspaceId);
} }
// Refresh after deletion // Refresh after deletion

View File

@@ -2,11 +2,13 @@ import 'package:flutter/material.dart';
import 'package:qpay/http/http.dart'; import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart'; import 'package:qpay/models/api_response.dart';
import 'package:qpay/models/pageable_model.dart'; import 'package:qpay/models/pageable_model.dart';
import 'package:qpay/screens/groups/models/create_group_from_file_response.dart';
import 'package:qpay/screens/groups/models/recipient_group_model.dart'; import 'package:qpay/screens/groups/models/recipient_group_model.dart';
class GroupScreenModel { class GroupScreenModel {
List<RecipientGroup> groups = []; List<RecipientGroup> groups = [];
List<RecipientGroupMember> members = []; List<RecipientGroupMember> members = [];
PageableModel? membersPage;
bool isLoading = false; bool isLoading = false;
bool isLoadingMore = false; bool isLoadingMore = false;
String? errorMessage; String? errorMessage;
@@ -52,7 +54,7 @@ class GroupController extends ChangeNotifier {
} }
Future<ApiResponse<PageableModel>> listGroups({ Future<ApiResponse<PageableModel>> listGroups({
required String userId, required String workspaceId,
int page = 0, int page = 0,
int size = 20, int size = 20,
String? name, String? name,
@@ -68,7 +70,7 @@ class GroupController extends ChangeNotifier {
try { try {
final params = <String, String>{ final params = <String, String>{
'userId': userId, 'workspaceId': workspaceId,
'page': page.toString(), 'page': page.toString(),
'size': size.toString(), 'size': size.toString(),
}; };
@@ -77,11 +79,12 @@ class GroupController extends ChangeNotifier {
} }
final raw = await http.get( final raw = await http.get(
'/public/recipient-groups?${buildQueryParameters(params)}', '/recipient-groups?${buildQueryParameters(params)}',
); );
final response = _unwrap(raw); final response = _unwrap(raw);
final pageableModel = final pageableModel = PageableModel.fromJson(
PageableModel.fromJson(response as Map<String, dynamic>); response as Map<String, dynamic>,
);
final newGroups = pageableModel.content final newGroups = pageableModel.content
.map((e) => RecipientGroup.fromJson(e as Map<String, dynamic>)) .map((e) => RecipientGroup.fromJson(e as Map<String, dynamic>))
@@ -113,20 +116,17 @@ class GroupController extends ChangeNotifier {
} }
} }
Future<void> searchGroups({ Future<void> searchGroups({required String workspaceId, String? name}) async {
required String userId,
String? name,
}) async {
model.searchQuery = name ?? ''; model.searchQuery = name ?? '';
await listGroups(userId: userId, page: 0, name: name); await listGroups(workspaceId: workspaceId, page: 0, name: name);
} }
Future<void> loadNextPage({required String userId}) async { Future<void> loadNextPage({required String workspaceId}) async {
if (model.isLoadingMore || model.currentPage + 1 >= model.totalPages) { if (model.isLoadingMore || model.currentPage + 1 >= model.totalPages) {
return; return;
} }
await listGroups( await listGroups(
userId: userId, workspaceId: workspaceId,
page: model.currentPage + 1, page: model.currentPage + 1,
name: model.searchQuery.isNotEmpty ? model.searchQuery : null, name: model.searchQuery.isNotEmpty ? model.searchQuery : null,
); );
@@ -139,9 +139,10 @@ class GroupController extends ChangeNotifier {
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
try { try {
final raw = await http.get( final raw = await http.get(
'/public/recipient-groups/members?recipientGroupId=$groupId', '/recipient-groups/members?recipientGroupId=$groupId',
); );
final response = _unwrap(raw); final response = _unwrap(raw);
model.membersPage = PageableModel.fromJson(response);
final List<dynamic> content; final List<dynamic> content;
if (response is List) { if (response is List) {
content = response; content = response;
@@ -173,7 +174,7 @@ class GroupController extends ChangeNotifier {
model.isLoading = true; model.isLoading = true;
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
try { try {
final raw = await http.post('/public/recipient-groups', req.toJson()); final raw = await http.post('/recipient-groups', req.toJson());
final response = _unwrap(raw); final response = _unwrap(raw);
final group = RecipientGroup.fromJson(response as Map<String, dynamic>); final group = RecipientGroup.fromJson(response as Map<String, dynamic>);
model.groups = [group, ...model.groups]; model.groups = [group, ...model.groups];
@@ -191,11 +192,11 @@ class GroupController extends ChangeNotifier {
Future<void> deleteGroup({ Future<void> deleteGroup({
required String groupId, required String groupId,
required String userId, required String workspaceId,
}) async { }) async {
try { try {
await http.delete( await http.delete(
'/public/recipient-groups/delete/$groupId?userId=$userId', '/recipient-groups/delete/$groupId?workspaceId=$workspaceId',
); );
model.groups.removeWhere((g) => g.id == groupId); model.groups.removeWhere((g) => g.id == groupId);
model.selectedGroupIds.remove(groupId); model.selectedGroupIds.remove(groupId);
@@ -227,11 +228,11 @@ class GroupController extends ChangeNotifier {
Future<void> removeMember({ Future<void> removeMember({
required String groupId, required String groupId,
required String recipientId, required String recipientId,
required String userId, required String workspaceId,
}) async { }) async {
try { try {
await http.delete( await http.delete(
'/public/recipient-groups/$groupId/members/$recipientId?userId=$userId', '/recipient-groups/$groupId/members/$recipientId?workspaceId=$workspaceId',
); );
model.members.removeWhere((m) => m.id == recipientId); model.members.removeWhere((m) => m.id == recipientId);
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
@@ -242,14 +243,62 @@ class GroupController extends ChangeNotifier {
} }
} }
Future<void> deleteSelectedGroups({required String userId}) async { Future<void> deleteSelectedGroups({required String workspaceId}) async {
final idsToDelete = List<String>.from(model.selectedGroupIds); final idsToDelete = List<String>.from(model.selectedGroupIds);
model.selectMode = false; model.selectMode = false;
model.selectedGroupIds.clear(); model.selectedGroupIds.clear();
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
for (final id in idsToDelete) { for (final id in idsToDelete) {
await deleteGroup(groupId: id, userId: userId); await deleteGroup(groupId: id, workspaceId: workspaceId);
}
}
Future<ApiResponse<CreateGroupFromFileResponse>> createGroupFromFile({
required String groupName,
required String workspaceId,
required String userId,
String? description,
required String currency,
required List<int> fileBytes,
required String fileName,
}) async {
model.isLoading = true;
if (!_disposed) notifyListeners();
try {
final fields = <String, String>{
'groupName': groupName,
'workspaceId': workspaceId,
'userId': userId,
'currency': currency,
};
if (description != null && description.isNotEmpty) {
fields['description'] = description;
}
final raw = await http.multipartPost(
'/recipient-groups/create-from-file',
fields,
'file',
fileBytes,
fileName,
);
final response = _unwrap(raw);
final result = CreateGroupFromFileResponse.fromJson(
response as Map<String, dynamic>,
);
model.isLoading = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(result);
} catch (e) {
logger.e(e);
model.errorMessage = e.toString();
model.isLoading = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to create group from file');
} }
} }
} }

View File

@@ -0,0 +1,46 @@
import 'package:json_annotation/json_annotation.dart';
part 'batch_item_update_model.g.dart';
@JsonSerializable(explicitToJson: true)
class GroupBatchItemUpdateRequest {
final String? id;
final double? amount;
final String? recipientName;
final String? recipientPhone;
final String? billClientId;
final String? billName;
final String? billProductName;
final String? providerImage;
final String? providerLabel;
const GroupBatchItemUpdateRequest({
this.id,
this.amount,
this.recipientName,
this.recipientPhone,
this.billClientId,
this.billName,
this.billProductName,
this.providerImage,
this.providerLabel,
});
factory GroupBatchItemUpdateRequest.fromJson(Map<String, dynamic> json) =>
_$GroupBatchItemUpdateRequestFromJson(json);
Map<String, dynamic> toJson() => _$GroupBatchItemUpdateRequestToJson(this);
}
@JsonSerializable(explicitToJson: true)
class GroupBatchItemUpdateList {
final List<GroupBatchItemUpdateRequest>? items;
final String? workspaceId;
const GroupBatchItemUpdateList({this.items, this.workspaceId});
factory GroupBatchItemUpdateList.fromJson(Map<String, dynamic> json) =>
_$GroupBatchItemUpdateListFromJson(json);
Map<String, dynamic> toJson() => _$GroupBatchItemUpdateListToJson(this);
}

View File

@@ -0,0 +1,53 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'batch_item_update_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
GroupBatchItemUpdateRequest _$GroupBatchItemUpdateRequestFromJson(
Map<String, dynamic> json,
) => GroupBatchItemUpdateRequest(
id: json['id'] as String?,
amount: (json['amount'] as num?)?.toDouble(),
recipientName: json['recipientName'] as String?,
recipientPhone: json['recipientPhone'] as String?,
billClientId: json['billClientId'] as String?,
billName: json['billName'] as String?,
billProductName: json['billProductName'] as String?,
providerImage: json['providerImage'] as String?,
providerLabel: json['providerLabel'] as String?,
);
Map<String, dynamic> _$GroupBatchItemUpdateRequestToJson(
GroupBatchItemUpdateRequest instance,
) => <String, dynamic>{
'id': instance.id,
'amount': instance.amount,
'recipientName': instance.recipientName,
'recipientPhone': instance.recipientPhone,
'billClientId': instance.billClientId,
'billName': instance.billName,
'billProductName': instance.billProductName,
'providerImage': instance.providerImage,
'providerLabel': instance.providerLabel,
};
GroupBatchItemUpdateList _$GroupBatchItemUpdateListFromJson(
Map<String, dynamic> json,
) => GroupBatchItemUpdateList(
items: (json['items'] as List<dynamic>?)
?.map(
(e) => GroupBatchItemUpdateRequest.fromJson(e as Map<String, dynamic>),
)
.toList(),
workspaceId: json['workspaceId'] as String?,
);
Map<String, dynamic> _$GroupBatchItemUpdateListToJson(
GroupBatchItemUpdateList instance,
) => <String, dynamic>{
'items': instance.items?.map((e) => e.toJson()).toList(),
'workspaceId': instance.workspaceId,
};

View File

@@ -0,0 +1,48 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:qpay/screens/groups/models/group_batch_model.dart';
part 'create_group_from_file_response.g.dart';
@JsonSerializable(explicitToJson: true)
class FileUploadError {
final int lineNumber;
final String account;
final String name;
final String message;
const FileUploadError({
required this.lineNumber,
required this.account,
required this.name,
required this.message,
});
factory FileUploadError.fromJson(Map<String, dynamic> json) =>
_$FileUploadErrorFromJson(json);
Map<String, dynamic> toJson() => _$FileUploadErrorToJson(this);
}
@JsonSerializable(explicitToJson: true)
class CreateGroupFromFileResponse {
final GroupBatch? batch;
final int totalRows;
final int successCount;
final int errorCount;
final List<FileUploadError> errors;
const CreateGroupFromFileResponse({
this.batch,
required this.totalRows,
required this.successCount,
required this.errorCount,
required this.errors,
});
factory CreateGroupFromFileResponse.fromJson(Map<String, dynamic> json) =>
_$CreateGroupFromFileResponseFromJson(json);
Map<String, dynamic> toJson() => _$CreateGroupFromFileResponseToJson(this);
bool get isSuccess => errors.isEmpty;
}

View File

@@ -0,0 +1,47 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'create_group_from_file_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
FileUploadError _$FileUploadErrorFromJson(Map<String, dynamic> json) =>
FileUploadError(
lineNumber: (json['lineNumber'] as num).toInt(),
account: json['account'] as String,
name: json['name'] as String,
message: json['message'] as String,
);
Map<String, dynamic> _$FileUploadErrorToJson(FileUploadError instance) =>
<String, dynamic>{
'lineNumber': instance.lineNumber,
'account': instance.account,
'name': instance.name,
'message': instance.message,
};
CreateGroupFromFileResponse _$CreateGroupFromFileResponseFromJson(
Map<String, dynamic> json,
) => CreateGroupFromFileResponse(
batch: json['batch'] == null
? null
: GroupBatch.fromJson(json['batch'] as Map<String, dynamic>),
totalRows: (json['totalRows'] as num).toInt(),
successCount: (json['successCount'] as num).toInt(),
errorCount: (json['errorCount'] as num).toInt(),
errors: (json['errors'] as List<dynamic>)
.map((e) => FileUploadError.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$CreateGroupFromFileResponseToJson(
CreateGroupFromFileResponse instance,
) => <String, dynamic>{
'batch': instance.batch?.toJson(),
'totalRows': instance.totalRows,
'successCount': instance.successCount,
'errorCount': instance.errorCount,
'errors': instance.errors.map((e) => e.toJson()).toList(),
};

View File

@@ -7,6 +7,7 @@ class GroupBatch {
final String? id; final String? id;
final String? recipientGroupId; final String? recipientGroupId;
final String? userId; final String? userId;
final String? workspaceId;
final String? currency; final String? currency;
final String? description; final String? description;
final double? value; final double? value;
@@ -24,6 +25,7 @@ class GroupBatch {
this.id, this.id,
this.recipientGroupId, this.recipientGroupId,
this.userId, this.userId,
this.workspaceId,
this.currency, this.currency,
this.description, this.description,
this.value, this.value,
@@ -50,22 +52,34 @@ class GroupBatchItem {
final String? groupBatchId; final String? groupBatchId;
final String? recipientName; final String? recipientName;
final String? recipientPhone; final String? recipientPhone;
final String? recipientAccount;
final double? amount; final double? amount;
final String? status; final String? status;
final String? transactionId; final String? transactionId;
final String? errorMessage; final String? errorMessage;
final String? createdAt; final String? createdAt;
final String? billName;
final String? billProductName;
final String? providerLabel;
final String? providerImage;
final String? creditAddress;
const GroupBatchItem({ const GroupBatchItem({
this.id, this.id,
this.groupBatchId, this.groupBatchId,
this.recipientName, this.recipientName,
this.recipientPhone, this.recipientPhone,
this.recipientAccount,
this.amount, this.amount,
this.status, this.status,
this.transactionId, this.transactionId,
this.errorMessage, this.errorMessage,
this.createdAt, this.createdAt,
this.billName,
this.billProductName,
this.providerLabel,
this.providerImage,
this.creditAddress,
}); });
factory GroupBatchItem.fromJson(Map<String, dynamic> json) => factory GroupBatchItem.fromJson(Map<String, dynamic> json) =>
@@ -78,24 +92,24 @@ class GroupBatchItem {
class CreateBatchRequest { class CreateBatchRequest {
final String recipientGroupId; final String recipientGroupId;
final String userId; final String userId;
final String workspaceId;
final String currency; final String currency;
final String description; final String description;
final double amount; final double amount;
final String? paymentProcessorLabel; final String? paymentProcessorLabel;
final String? paymentProcessorName; final String? paymentProcessorName;
final String? paymentProcessorImage; final String? paymentProcessorImage;
final Map<String, dynamic> transactionTemplate;
const CreateBatchRequest({ const CreateBatchRequest({
required this.recipientGroupId, required this.recipientGroupId,
required this.userId, required this.userId,
required this.workspaceId,
required this.currency, required this.currency,
required this.description, required this.description,
required this.amount, required this.amount,
this.paymentProcessorLabel, this.paymentProcessorLabel,
this.paymentProcessorName, this.paymentProcessorName,
this.paymentProcessorImage, this.paymentProcessorImage,
required this.transactionTemplate,
}); });
factory CreateBatchRequest.fromJson(Map<String, dynamic> json) => factory CreateBatchRequest.fromJson(Map<String, dynamic> json) =>
@@ -106,12 +120,14 @@ class CreateBatchRequest {
@JsonSerializable() @JsonSerializable()
class BatchActionRequest { class BatchActionRequest {
final String userId; final String workspaceId;
final String idempotencyKey; final String idempotencyKey;
final String? authType;
const BatchActionRequest({ const BatchActionRequest({
required this.userId, required this.workspaceId,
required this.idempotencyKey, required this.idempotencyKey,
this.authType,
}); });
factory BatchActionRequest.fromJson(Map<String, dynamic> json) => factory BatchActionRequest.fromJson(Map<String, dynamic> json) =>
@@ -123,14 +139,14 @@ class BatchActionRequest {
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class GroupBatchUpdateRequest { class GroupBatchUpdateRequest {
String? id; String? id;
String? userId; String? workspaceId;
String? paymentProcessorLabel; String? paymentProcessorLabel;
String? paymentProcessorName; String? paymentProcessorName;
String? paymentProcessorImage; String? paymentProcessorImage;
GroupBatchUpdateRequest({ GroupBatchUpdateRequest({
this.id, this.id,
this.userId, this.workspaceId,
this.paymentProcessorLabel, this.paymentProcessorLabel,
this.paymentProcessorName, this.paymentProcessorName,
this.paymentProcessorImage, this.paymentProcessorImage,

View File

@@ -10,6 +10,7 @@ GroupBatch _$GroupBatchFromJson(Map<String, dynamic> json) => GroupBatch(
id: json['id'] as String?, id: json['id'] as String?,
recipientGroupId: json['recipientGroupId'] as String?, recipientGroupId: json['recipientGroupId'] as String?,
userId: json['userId'] as String?, userId: json['userId'] as String?,
workspaceId: json['workspaceId'] as String?,
currency: json['currency'] as String?, currency: json['currency'] as String?,
description: json['description'] as String?, description: json['description'] as String?,
value: (json['value'] as num?)?.toDouble(), value: (json['value'] as num?)?.toDouble(),
@@ -29,6 +30,7 @@ Map<String, dynamic> _$GroupBatchToJson(GroupBatch instance) =>
'id': instance.id, 'id': instance.id,
'recipientGroupId': instance.recipientGroupId, 'recipientGroupId': instance.recipientGroupId,
'userId': instance.userId, 'userId': instance.userId,
'workspaceId': instance.workspaceId,
'currency': instance.currency, 'currency': instance.currency,
'description': instance.description, 'description': instance.description,
'value': instance.value, 'value': instance.value,
@@ -49,11 +51,17 @@ GroupBatchItem _$GroupBatchItemFromJson(Map<String, dynamic> json) =>
groupBatchId: json['groupBatchId'] as String?, groupBatchId: json['groupBatchId'] as String?,
recipientName: json['recipientName'] as String?, recipientName: json['recipientName'] as String?,
recipientPhone: json['recipientPhone'] as String?, recipientPhone: json['recipientPhone'] as String?,
recipientAccount: json['recipientAccount'] as String?,
amount: (json['amount'] as num?)?.toDouble(), amount: (json['amount'] as num?)?.toDouble(),
status: json['status'] as String?, status: json['status'] as String?,
transactionId: json['transactionId'] as String?, transactionId: json['transactionId'] as String?,
errorMessage: json['errorMessage'] as String?, errorMessage: json['errorMessage'] as String?,
createdAt: json['createdAt'] as String?, createdAt: json['createdAt'] as String?,
billName: json['billName'] as String?,
billProductName: json['billProductName'] as String?,
providerLabel: json['providerLabel'] as String?,
providerImage: json['providerImage'] as String?,
creditAddress: json['creditAddress'] as String?,
); );
Map<String, dynamic> _$GroupBatchItemToJson(GroupBatchItem instance) => Map<String, dynamic> _$GroupBatchItemToJson(GroupBatchItem instance) =>
@@ -62,56 +70,64 @@ Map<String, dynamic> _$GroupBatchItemToJson(GroupBatchItem instance) =>
'groupBatchId': instance.groupBatchId, 'groupBatchId': instance.groupBatchId,
'recipientName': instance.recipientName, 'recipientName': instance.recipientName,
'recipientPhone': instance.recipientPhone, 'recipientPhone': instance.recipientPhone,
'recipientAccount': instance.recipientAccount,
'amount': instance.amount, 'amount': instance.amount,
'status': instance.status, 'status': instance.status,
'transactionId': instance.transactionId, 'transactionId': instance.transactionId,
'errorMessage': instance.errorMessage, 'errorMessage': instance.errorMessage,
'createdAt': instance.createdAt, 'createdAt': instance.createdAt,
'billName': instance.billName,
'billProductName': instance.billProductName,
'providerLabel': instance.providerLabel,
'providerImage': instance.providerImage,
'creditAddress': instance.creditAddress,
}; };
CreateBatchRequest _$CreateBatchRequestFromJson(Map<String, dynamic> json) => CreateBatchRequest _$CreateBatchRequestFromJson(Map<String, dynamic> json) =>
CreateBatchRequest( CreateBatchRequest(
recipientGroupId: json['recipientGroupId'] as String, recipientGroupId: json['recipientGroupId'] as String,
userId: json['userId'] as String, userId: json['userId'] as String,
workspaceId: json['workspaceId'] as String,
currency: json['currency'] as String, currency: json['currency'] as String,
description: json['description'] as String, description: json['description'] as String,
amount: (json['amount'] as num).toDouble(), amount: (json['amount'] as num).toDouble(),
paymentProcessorLabel: json['paymentProcessorLabel'] as String?, paymentProcessorLabel: json['paymentProcessorLabel'] as String?,
paymentProcessorName: json['paymentProcessorName'] as String?, paymentProcessorName: json['paymentProcessorName'] as String?,
paymentProcessorImage: json['paymentProcessorImage'] as String?, paymentProcessorImage: json['paymentProcessorImage'] as String?,
transactionTemplate: json['transactionTemplate'] as Map<String, dynamic>,
); );
Map<String, dynamic> _$CreateBatchRequestToJson(CreateBatchRequest instance) => Map<String, dynamic> _$CreateBatchRequestToJson(CreateBatchRequest instance) =>
<String, dynamic>{ <String, dynamic>{
'recipientGroupId': instance.recipientGroupId, 'recipientGroupId': instance.recipientGroupId,
'userId': instance.userId, 'userId': instance.userId,
'workspaceId': instance.workspaceId,
'currency': instance.currency, 'currency': instance.currency,
'description': instance.description, 'description': instance.description,
'amount': instance.amount, 'amount': instance.amount,
'paymentProcessorLabel': instance.paymentProcessorLabel, 'paymentProcessorLabel': instance.paymentProcessorLabel,
'paymentProcessorName': instance.paymentProcessorName, 'paymentProcessorName': instance.paymentProcessorName,
'paymentProcessorImage': instance.paymentProcessorImage, 'paymentProcessorImage': instance.paymentProcessorImage,
'transactionTemplate': instance.transactionTemplate,
}; };
BatchActionRequest _$BatchActionRequestFromJson(Map<String, dynamic> json) => BatchActionRequest _$BatchActionRequestFromJson(Map<String, dynamic> json) =>
BatchActionRequest( BatchActionRequest(
userId: json['userId'] as String, workspaceId: json['workspaceId'] as String,
idempotencyKey: json['idempotencyKey'] as String, idempotencyKey: json['idempotencyKey'] as String,
authType: json['authType'] as String?,
); );
Map<String, dynamic> _$BatchActionRequestToJson(BatchActionRequest instance) => Map<String, dynamic> _$BatchActionRequestToJson(BatchActionRequest instance) =>
<String, dynamic>{ <String, dynamic>{
'userId': instance.userId, 'workspaceId': instance.workspaceId,
'idempotencyKey': instance.idempotencyKey, 'idempotencyKey': instance.idempotencyKey,
'authType': instance.authType,
}; };
GroupBatchUpdateRequest _$GroupBatchUpdateRequestFromJson( GroupBatchUpdateRequest _$GroupBatchUpdateRequestFromJson(
Map<String, dynamic> json, Map<String, dynamic> json,
) => GroupBatchUpdateRequest( ) => GroupBatchUpdateRequest(
id: json['id'] as String?, id: json['id'] as String?,
userId: json['userId'] as String?, workspaceId: json['workspaceId'] as String?,
paymentProcessorLabel: json['paymentProcessorLabel'] as String?, paymentProcessorLabel: json['paymentProcessorLabel'] as String?,
paymentProcessorName: json['paymentProcessorName'] as String?, paymentProcessorName: json['paymentProcessorName'] as String?,
paymentProcessorImage: json['paymentProcessorImage'] as String?, paymentProcessorImage: json['paymentProcessorImage'] as String?,
@@ -121,7 +137,7 @@ Map<String, dynamic> _$GroupBatchUpdateRequestToJson(
GroupBatchUpdateRequest instance, GroupBatchUpdateRequest instance,
) => <String, dynamic>{ ) => <String, dynamic>{
'id': instance.id, 'id': instance.id,
'userId': instance.userId, 'workspaceId': instance.workspaceId,
'paymentProcessorLabel': instance.paymentProcessorLabel, 'paymentProcessorLabel': instance.paymentProcessorLabel,
'paymentProcessorName': instance.paymentProcessorName, 'paymentProcessorName': instance.paymentProcessorName,
'paymentProcessorImage': instance.paymentProcessorImage, 'paymentProcessorImage': instance.paymentProcessorImage,

View File

@@ -9,6 +9,7 @@ class RecipientGroup {
final String name; final String name;
final String? description; final String? description;
final String? userId; final String? userId;
final String? workspaceId;
final String? createdAt; final String? createdAt;
const RecipientGroup({ const RecipientGroup({
@@ -16,6 +17,7 @@ class RecipientGroup {
required this.name, required this.name,
this.description, this.description,
this.userId, this.userId,
this.workspaceId,
this.createdAt, this.createdAt,
}); });
@@ -56,12 +58,14 @@ class CreateGroupRequest {
final String name; final String name;
final String? description; final String? description;
final String userId; final String userId;
final String workspaceId;
final List<Recipient> recipients; final List<Recipient> recipients;
const CreateGroupRequest({ const CreateGroupRequest({
required this.name, required this.name,
this.description, this.description,
required this.userId, required this.userId,
required this.workspaceId,
required this.recipients, required this.recipients,
}); });

View File

@@ -12,6 +12,7 @@ RecipientGroup _$RecipientGroupFromJson(Map<String, dynamic> json) =>
name: json['name'] as String, name: json['name'] as String,
description: json['description'] as String?, description: json['description'] as String?,
userId: json['userId'] as String?, userId: json['userId'] as String?,
workspaceId: json['workspaceId'] as String?,
createdAt: json['createdAt'] as String?, createdAt: json['createdAt'] as String?,
); );
@@ -21,6 +22,7 @@ Map<String, dynamic> _$RecipientGroupToJson(RecipientGroup instance) =>
'name': instance.name, 'name': instance.name,
'description': instance.description, 'description': instance.description,
'userId': instance.userId, 'userId': instance.userId,
'workspaceId': instance.workspaceId,
'createdAt': instance.createdAt, 'createdAt': instance.createdAt,
}; };
@@ -53,6 +55,7 @@ CreateGroupRequest _$CreateGroupRequestFromJson(Map<String, dynamic> json) =>
name: json['name'] as String, name: json['name'] as String,
description: json['description'] as String?, description: json['description'] as String?,
userId: json['userId'] as String, userId: json['userId'] as String,
workspaceId: json['workspaceId'] as String,
recipients: (json['recipients'] as List<dynamic>) recipients: (json['recipients'] as List<dynamic>)
.map((e) => Recipient.fromJson(e as Map<String, dynamic>)) .map((e) => Recipient.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
@@ -63,5 +66,6 @@ Map<String, dynamic> _$CreateGroupRequestToJson(CreateGroupRequest instance) =>
'name': instance.name, 'name': instance.name,
'description': instance.description, 'description': instance.description,
'userId': instance.userId, 'userId': instance.userId,
'workspaceId': instance.workspaceId,
'recipients': instance.recipients.map((e) => e.toJson()).toList(), 'recipients': instance.recipients.map((e) => e.toJson()).toList(),
}; };

View File

@@ -0,0 +1,494 @@
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/batch_item_update_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/provider/provider_controller.dart';
import 'package:qpay/widgets/app_snack_bar.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 batchController;
late ProviderController providerController;
final _formKey = GlobalKey<FormState>();
final _descriptionController = TextEditingController();
final _amountController = TextEditingController();
final _debitPhoneController = TextEditingController();
String _currency = 'USD';
bool _loadingDropdowns = true;
@override
void initState() {
super.initState();
batchController = BatchController(context);
providerController = ProviderController(context);
// Sync listeners so the UI rebuilds when either controller changes
batchController.addListener(_onControllerChange);
providerController.addListener(_onControllerChange);
_init();
}
void _onControllerChange() {
if (!mounted) return;
setState(() {
// When products are available and a selected product exists with a non-zero
// defaultAmount, populate the amount field (only if it's empty or was cleared).
if (providerController.model.products.isNotEmpty &&
providerController.model.selectedProduct != null &&
_amountController.text.isEmpty) {
final product = providerController.model.selectedProduct!;
if (product.defaultAmount > 0) {
_amountController.text = product.defaultAmount.toStringAsFixed(2);
}
}
});
}
Future<void> _init() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.containsKey('phone')) {
_debitPhoneController.text = prefs.getString('phone')!;
}
await Future.wait([
providerController.loadProviders(_currency),
batchController.loadProcessors(_currency),
]);
if (mounted) setState(() => _loadingDropdowns = false);
}
@override
void dispose() {
batchController.removeListener(_onControllerChange);
providerController.removeListener(_onControllerChange);
batchController.dispose();
providerController.dispose();
_descriptionController.dispose();
_amountController.dispose();
_debitPhoneController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
final amount = double.tryParse(_amountController.text.trim());
if (amount == null || amount <= 0) {
AppSnackBar.show(context, 'Please enter a valid amount.');
return;
}
final prefs = await SharedPreferences.getInstance();
final userId = prefs.getString('userId') ?? '';
final workspaceId = prefs.getString('workspaceId') ?? '';
final req = CreateBatchRequest(
recipientGroupId: widget.group.id ?? '',
userId: userId,
workspaceId: workspaceId,
currency: _currency,
description: _descriptionController.text.trim(),
amount: amount,
);
// Step 1: Create the batch
final result = await batchController.createBatch(req);
if (!mounted) return;
if (result.isSuccess) {
final batch = result.data!;
// Step 2: Update batch items with selected provider & product
final selectedProvider = providerController.model.selectedProvider;
final selectedProduct = providerController.model.selectedProduct;
if (selectedProvider != null && batch.id != null) {
await _updateBatchItems(
batch.id!,
selectedProvider,
selectedProduct,
workspaceId,
);
}
if (!mounted) return;
context.pushReplacement('/groups/batches/${batch.id}');
} else {
AppSnackBar.showError(context, result.error ?? 'Failed to create batch.');
}
}
Future<void> _updateBatchItems(
String batchId,
BillProvider provider,
BillProduct? product,
String workspaceId,
) async {
// Fetch the batch items so we can update them
final itemsResult = await batchController.listBatchItems(batchId);
if (!mounted || !itemsResult.isSuccess) return;
if (itemsResult.data!.isEmpty) return;
final updateItems = itemsResult.data!.map((item) {
return GroupBatchItemUpdateRequest(
id: item.id,
amount: item.amount,
recipientName: item.recipientName,
recipientPhone: item.recipientPhone,
providerImage: provider.image,
providerLabel: provider.label,
billClientId: provider.clientId,
billName: provider.name,
billProductName: product?.name ?? '',
);
}).toList();
final updateList = GroupBatchItemUpdateList(
items: updateItems,
workspaceId: workspaceId,
);
await batchController.updateBatchItems(batchId, updateList);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('New Batch'), centerTitle: true),
body: ListenableBuilder(
listenable: Listenable.merge([batchController, providerController]),
builder: (context, _) {
if (_loadingDropdowns) {
return const Center(child: CircularProgressIndicator());
}
return Align(
alignment: Alignment.topCenter,
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: [
// Row 1: Description + Currency
Row(
children: [
Expanded(
flex: 2,
child: 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(width: 12),
Expanded(
child: DropdownButtonFormField<String>(
initialValue: _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) _updateCurrency(v);
},
),
),
],
),
const SizedBox(height: 16),
// Row 2: Provider + Product
Row(
children: [
Expanded(child: _buildProviderDropdown()),
const SizedBox(width: 12),
Expanded(child: _buildProductDropdown()),
],
),
const SizedBox(height: 16),
// Row 3: Amount + Phone
Row(
children: [
Expanded(
flex: 1,
child: TextFormField(
controller: _amountController,
readOnly: providerController
.model
.products
.isNotEmpty,
keyboardType:
const TextInputType.numberWithOptions(
decimal: true,
),
textInputAction: TextInputAction.next,
decoration: InputDecoration(
labelText:
'Amount each recipient will receive',
hintText:
providerController
.model
.products
.isNotEmpty
? 'Set by product'
: 'Amount',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (v) =>
(v == null || v.trim().isEmpty)
? 'Required'
: null,
),
),
const SizedBox(width: 12),
Expanded(
flex: 1,
child: 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: batchController.model.isActing
? null
: _submit,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: batchController.model.isActing
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'Create Batch',
style: TextStyle(fontSize: 16),
),
),
],
),
),
),
);
},
),
);
},
),
);
}
Widget _buildProviderDropdown() {
final providers = providerController.model.providers;
final isDark = Theme.of(context).brightness == Brightness.dark;
if (providerController.model.isLoadingProviders) {
return const SizedBox(
height: 56,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
}
if (providers.isEmpty) {
return const SizedBox(
height: 56,
child: Center(child: Text('No providers available')),
);
}
return DropdownButtonFormField<BillProvider>(
initialValue: providerController.model.selectedProvider,
isExpanded: true,
decoration: InputDecoration(
labelText: 'Provider',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 14,
),
),
items: providers.map((provider) {
return DropdownMenuItem<BillProvider>(
value: provider,
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: Image.asset(
'assets/${provider.image}',
width: 24,
height: 24,
errorBuilder: (_, __, ___) =>
const Icon(Icons.payment_rounded, size: 20),
),
),
const SizedBox(width: 8),
Flexible(
child: Text(
provider.name,
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.white : Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}).toList(),
onChanged: (provider) {
if (provider != null) {
_amountController.clear();
providerController.selectProvider(provider);
}
},
);
}
Widget _buildProductDropdown() {
final products = providerController.model.products;
final isDark = Theme.of(context).brightness == Brightness.dark;
if (providerController.model.selectedProvider == null) {
return InputDecorator(
decoration: InputDecoration(
labelText: 'Product',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 14,
),
),
child: Text(
'Select a provider first',
style: TextStyle(fontSize: 13, color: Colors.grey.shade500),
),
);
}
if (providerController.model.isLoadingProducts) {
return const SizedBox(
height: 56,
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
);
}
if (products.isEmpty) {
return InputDecorator(
decoration: InputDecoration(
labelText: 'Product',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 14,
),
),
child: Text(
'No products',
style: TextStyle(fontSize: 13, color: Colors.grey.shade500),
),
);
}
return DropdownButtonFormField<BillProduct>(
value: providerController.model.selectedProduct,
isExpanded: true,
decoration: InputDecoration(
labelText: 'Product',
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 14,
),
),
items: products.map((product) {
return DropdownMenuItem<BillProduct>(
value: product,
child: Text(
'${product.displayName.isNotEmpty ? product.displayName : product.name} - \$${product.defaultAmount}',
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.white : Colors.black87,
),
overflow: TextOverflow.ellipsis,
),
);
}).toList(),
onChanged: (product) {
if (product != null) {
providerController.selectProduct(product);
if (product.defaultAmount > 0) {
_amountController.text = product.defaultAmount.toStringAsFixed(2);
}
}
},
);
}
void _updateCurrency(String v) {
setState(() => _currency = v);
providerController.loadProviders(v);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,479 @@
import 'dart:io' show File, Platform;
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:file_picker/file_picker.dart';
import 'package:go_router/go_router.dart';
import 'package:path_provider/path_provider.dart';
import 'package:qpay/screens/groups/controllers/group_controller.dart';
import 'package:qpay/screens/groups/models/create_group_from_file_response.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/widgets/app_snack_bar.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
class CreateGroupFromFileScreen extends StatefulWidget {
const CreateGroupFromFileScreen({super.key});
@override
State<CreateGroupFromFileScreen> createState() =>
_CreateGroupFromFileScreenState();
}
class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
late GroupController controller;
final _formKey = GlobalKey<FormState>();
final _groupNameController = TextEditingController();
final _descriptionController = TextEditingController();
String _currency = 'USD';
List<int>? _fileBytes;
String? _selectedFileName;
@override
void initState() {
super.initState();
controller = GroupController(context);
}
@override
void dispose() {
_groupNameController.dispose();
_descriptionController.dispose();
controller.dispose();
super.dispose();
}
Future<void> _downloadTemplate() async {
try {
final bytes = await rootBundle.load('assets/group-batch-example.csv');
final data = bytes.buffer.asUint8List();
if (kIsWeb) {
// Web: use url_launcher with a data URI
final uri = Uri.dataFromString(
String.fromCharCodes(data),
mimeType: 'text/csv',
encoding: null,
);
await launchUrl(uri, mode: LaunchMode.platformDefault);
} else {
// Mobile/desktop: write to temp file and open with url_launcher
final dir = Platform.isAndroid
? await getTemporaryDirectory()
: await getApplicationDocumentsDirectory();
final file = File('${dir.path}/group-batch-example.csv');
await file.writeAsBytes(data);
final fileUri = Uri.file(file.path);
if (await canLaunchUrl(fileUri)) {
await launchUrl(fileUri, mode: LaunchMode.externalApplication);
} else {
throw Exception('Cannot open file');
}
}
} catch (e) {
if (mounted) {
AppSnackBar.showError(context, 'Failed to download template: $e');
}
}
}
Future<void> _pickFile() async {
final result = await FilePicker.pickFiles(
type: FileType.custom,
allowedExtensions: ['csv'],
withData: true,
);
if (result != null && result.files.isNotEmpty) {
final file = result.files.first;
if (file.bytes != null) {
setState(() {
_fileBytes = file.bytes;
_selectedFileName = file.name;
});
}
}
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
if (_fileBytes == null || _selectedFileName == null) {
AppSnackBar.show(context, 'Please select a CSV file to upload.');
return;
}
final prefs = await SharedPreferences.getInstance();
final workspaceId = prefs.getString('workspaceId') ?? '';
final userId = prefs.getString('userId') ?? '';
final result = await controller.createGroupFromFile(
groupName: _groupNameController.text.trim(),
workspaceId: workspaceId,
userId: userId,
description: _descriptionController.text.trim().isEmpty
? null
: _descriptionController.text.trim(),
currency: _currency,
fileBytes: _fileBytes!,
fileName: _selectedFileName!,
);
if (!mounted) return;
if (result.isSuccess) {
final response = result.data!;
if (response.isSuccess && response.batch != null) {
final batch = response.batch!;
final groupId = batch.recipientGroupId ?? '';
AppSnackBar.showSuccess(
context,
'Group & batch created successfully. '
'${response.successCount} of ${response.totalRows} rows processed.',
);
if (groupId.isNotEmpty && batch.id != null) {
context.pushReplacement('/groups/batches/${batch.id}');
}
} else {
// Partial failure — show errors
_showErrorsBottomSheet(response);
}
} else {
AppSnackBar.showError(
context,
result.error ?? 'Failed to create group from file.',
);
}
}
void _showErrorsBottomSheet(CreateGroupFromFileResponse response) {
final isDark = Theme.of(context).brightness == Brightness.dark;
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
return Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
),
Row(
children: [
Icon(
Icons.warning_amber_rounded,
color: Colors.orange.shade700,
size: 24,
),
const SizedBox(width: 10),
Text(
'Upload completed with errors',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
),
),
],
),
const SizedBox(height: 8),
Text(
'${response.errorCount} of ${response.totalRows} rows failed. '
'${response.successCount} rows succeeded.',
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
const SizedBox(height: 16),
if (response.errors.isNotEmpty) ...[
Flexible(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(sheetContext).size.height * 0.4,
),
child: ListView.separated(
shrinkWrap: true,
itemCount: response.errors.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, index) {
final error = response.errors[index];
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
radius: 14,
backgroundColor: Colors.red.shade50,
child: Text(
'${error.lineNumber}',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: Colors.red.shade700,
),
),
),
title: Text(
error.name.isNotEmpty
? '${error.name} (${error.account})'
: error.account,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
subtitle: Text(
error.message,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
),
);
},
),
),
),
const SizedBox(height: 16),
],
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.of(sheetContext).pop(),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: const Text('Dismiss'),
),
),
],
),
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Create from File'), 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: _groupNameController,
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: 16),
DropdownButtonFormField<String>(
initialValue: _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: 24),
Text(
'Recipients File',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey.shade800,
),
),
const SizedBox(height: 4),
Text(
'Upload a CSV file with columns: name, account, amount, billProductName',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 12),
InkWell(
onTap: _pickFile,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
border: Border.all(
color: _fileBytes != null
? Theme.of(context).colorScheme.primary
: Colors.grey.shade300,
width: 1.5,
style: BorderStyle.solid,
),
borderRadius: BorderRadius.circular(12),
color: _fileBytes != null
? Theme.of(
context,
).colorScheme.primary.withAlpha(12)
: Colors.grey.shade50,
),
child: Column(
children: [
Icon(
_fileBytes != null
? Icons.insert_drive_file
: Icons.cloud_upload_outlined,
size: 40,
color: _fileBytes != null
? Theme.of(context).colorScheme.primary
: Colors.grey.shade400,
),
const SizedBox(height: 8),
Text(
_selectedFileName ??
'Tap to select CSV file',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: _fileBytes != null
? Theme.of(
context,
).colorScheme.primary
: Colors.grey.shade600,
),
textAlign: TextAlign.center,
),
if (_fileBytes == null) ...[
const SizedBox(height: 4),
Text(
'Accepted format: .csv',
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
),
),
],
],
),
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: _downloadTemplate,
icon: const Icon(
Icons.download_rounded,
size: 18,
),
label: const Text('Download Template'),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
visualDensity: VisualDensity.compact,
),
),
),
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(
'Upload & Create',
style: TextStyle(fontSize: 16),
),
),
],
),
),
),
);
},
),
);
},
),
);
}
}

View File

@@ -6,7 +6,7 @@ import 'package:qpay/screens/groups/controllers/group_controller.dart';
import 'package:qpay/widgets/app_snack_bar.dart'; import 'package:qpay/widgets/app_snack_bar.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../recipient/recipients_controller.dart'; import '../../recipient/recipients_controller.dart';
class GroupCreateScreen extends StatefulWidget { class GroupCreateScreen extends StatefulWidget {
const GroupCreateScreen({super.key}); const GroupCreateScreen({super.key});
@@ -49,12 +49,16 @@ class _GroupCreateScreenState extends State<GroupCreateScreen> {
if (parts.length >= 2) { if (parts.length >= 2) {
final name = parts[0].trim(); final name = parts[0].trim();
final account = parts[1].trim(); final account = parts[1].trim();
String phone = '';
if (parts.length > 2) {
phone = parts.sublist(2).join(',').trim();
}
if (name.isNotEmpty && account.isNotEmpty) { if (name.isNotEmpty && account.isNotEmpty) {
members.add( members.add(
Recipient( Recipient(
name: name, name: name,
account: account, account: account,
phoneNumber: account, phoneNumber: phone,
latestProviderLabel: '', latestProviderLabel: '',
), ),
); );
@@ -78,6 +82,7 @@ class _GroupCreateScreenState extends State<GroupCreateScreen> {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final userId = prefs.getString('userId') ?? ''; final userId = prefs.getString('userId') ?? '';
final workspaceId = prefs.getString('workspaceId') ?? '';
final req = CreateGroupRequest( final req = CreateGroupRequest(
name: _nameController.text.trim(), name: _nameController.text.trim(),
@@ -85,6 +90,7 @@ class _GroupCreateScreenState extends State<GroupCreateScreen> {
? null ? null
: _descriptionController.text.trim(), : _descriptionController.text.trim(),
userId: userId, userId: userId,
workspaceId: workspaceId,
recipients: recipients, recipients: recipients,
); );
@@ -95,10 +101,7 @@ class _GroupCreateScreenState extends State<GroupCreateScreen> {
AppSnackBar.showSuccess(context, 'Group created successfully.'); AppSnackBar.showSuccess(context, 'Group created successfully.');
context.pop(true); // Return true to indicate a new group was created context.pop(true); // Return true to indicate a new group was created
} else { } else {
AppSnackBar.showError( AppSnackBar.showError(context, result.error ?? 'Failed to create group.');
context,
result.error ?? 'Failed to create group.',
);
} }
} }

View File

@@ -30,26 +30,28 @@ class _GroupsScreenState extends State<GroupsScreen> {
void _onScroll() { void _onScroll() {
if (_scrollController.position.pixels >= if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) { _scrollController.position.maxScrollExtent - 200) {
controller.loadNextPage(userId: prefs.getString('userId') ?? ''); controller.loadNextPage(
workspaceId: prefs.getString('workspaceId') ?? '',
);
} }
} }
Future<void> _loadData() async { Future<void> _loadData() async {
prefs = await SharedPreferences.getInstance(); prefs = await SharedPreferences.getInstance();
final userId = prefs.getString('userId') ?? ''; final workspaceId = prefs.getString('workspaceId') ?? '';
await controller.listGroups(userId: userId); await controller.listGroups(workspaceId: workspaceId);
} }
void _onSearchChanged(String value) { void _onSearchChanged(String value) {
final userId = prefs.getString('userId') ?? ''; final workspaceId = prefs.getString('workspaceId') ?? '';
controller.searchGroups( controller.searchGroups(
userId: userId, workspaceId: workspaceId,
name: value.isNotEmpty ? value : null, name: value.isNotEmpty ? value : null,
); );
} }
void _onDeleteSelected() async { void _onDeleteSelected() async {
final userId = prefs.getString('userId') ?? ''; final workspaceId = prefs.getString('workspaceId') ?? '';
final count = controller.model.selectedGroupIds.length; final count = controller.model.selectedGroupIds.length;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
@@ -72,7 +74,7 @@ class _GroupsScreenState extends State<GroupsScreen> {
); );
if (confirmed == true) { if (confirmed == true) {
await controller.deleteSelectedGroups(userId: userId); await controller.deleteSelectedGroups(workspaceId: workspaceId);
} }
} }
@@ -196,11 +198,149 @@ class _GroupsScreenState extends State<GroupsScreen> {
); );
} }
_createGroup() async { _createGroup() {
final response = await context.push('/groups/create'); _showCreateGroupBottomSheet();
if (response == true) { }
_loadData();
} void _showCreateGroupBottomSheet() {
final isDark = Theme.of(context).brightness == Brightness.dark;
showModalBottomSheet(
context: context,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
return Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
),
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
),
Text(
'Create Group',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 6),
Text(
'Choose how you would like to create a group.',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.grey.shade500,
),
),
const SizedBox(height: 20),
_buildCreateOption(
sheetContext,
icon: Icons.edit_note,
title: 'Create via Form',
subtitle: 'Manually enter group details and recipients',
onTap: () async {
Navigator.of(sheetContext).pop();
final response = await context.push('/groups/create');
if (response == true) {
_loadData();
}
},
isDark: isDark,
),
const SizedBox(height: 12),
_buildCreateOption(
sheetContext,
icon: Icons.upload_file,
title: 'Create from File',
subtitle: 'Upload a CSV file with group & batch data',
onTap: () {
Navigator.of(sheetContext).pop();
context.push('/groups/create-from-file');
},
isDark: isDark,
),
],
),
);
},
);
}
Widget _buildCreateOption(
BuildContext sheetContext, {
required IconData icon,
required String title,
required String subtitle,
required VoidCallback onTap,
required bool isDark,
}) {
final theme = Theme.of(context);
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isDark
? Colors.white.withValues(alpha: 0.05)
: Colors.grey.shade50,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.08)
: Colors.grey.shade200,
),
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: theme.colorScheme.primary.withAlpha(25),
borderRadius: BorderRadius.circular(12),
),
child: Icon(icon, color: theme.colorScheme.primary, size: 24),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 2),
Text(
subtitle,
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
],
),
),
Icon(Icons.chevron_right, color: Colors.grey.shade400),
],
),
),
);
} }
Widget _buildIconButton({ Widget _buildIconButton({
@@ -295,86 +435,112 @@ class _GroupsScreenState extends State<GroupsScreen> {
} }
Widget _buildGroupCard(RecipientGroup group) { Widget _buildGroupCard(RecipientGroup group) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final isSelected = controller.model.selectedGroupIds.contains(group.id); final isSelected = controller.model.selectedGroupIds.contains(group.id);
final inSelectMode = controller.model.selectMode; final inSelectMode = controller.model.selectMode;
return Card( return Padding(
elevation: 0, padding: const EdgeInsets.only(bottom: 8),
shape: RoundedRectangleBorder( child: Material(
borderRadius: BorderRadius.circular(12), color: Colors.transparent,
side: BorderSide( child: InkWell(
color: inSelectMode && isSelected borderRadius: BorderRadius.circular(16),
? Theme.of(context).colorScheme.primary onTap: inSelectMode
: Colors.black12.withAlpha(30), ? () => controller.toggleGroupSelection(group.id!)
width: inSelectMode && isSelected ? 2 : 1, : () => context.push('/groups/${group.id}', extra: group),
), child: Container(
), padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 14),
child: InkWell( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12), color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
onTap: inSelectMode borderRadius: BorderRadius.circular(16),
? () => controller.toggleGroupSelection(group.id!) border: Border.all(
: () => context.push('/groups/${group.id}', extra: group), color: inSelectMode && isSelected
child: Padding( ? theme.colorScheme.primary
padding: const EdgeInsets.all(16), : isDark
child: Row( ? Colors.white.withValues(alpha: 0.06)
children: [ : Colors.grey.shade200,
if (inSelectMode) width: inSelectMode && isSelected ? 2 : 1,
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), boxShadow: [
Expanded( BoxShadow(
child: Column( color: isDark
crossAxisAlignment: CrossAxisAlignment.start, ? Colors.black.withValues(alpha: 0.2)
children: [ : Colors.black.withValues(alpha: 0.03),
Text( blurRadius: 8,
group.name, offset: const Offset(0, 2),
style: const TextStyle( ),
fontSize: 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.colorScheme.primary
: Colors.grey.shade400,
),
),
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
_getGroupAbbreviation(group.name),
style: TextStyle(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
fontSize: 16,
), ),
), ),
if (group.description != null && ),
group.description!.isNotEmpty) ),
Padding( const SizedBox(width: 14),
padding: const EdgeInsets.only(top: 4), Expanded(
child: Text( child: Column(
group.description!, crossAxisAlignment: CrossAxisAlignment.start,
style: TextStyle( children: [
fontSize: 13, Text(
color: Colors.grey.shade600, group.name,
), style: TextStyle(
maxLines: 1, fontSize: 14,
overflow: TextOverflow.ellipsis, fontWeight: FontWeight.w600,
color: isDark ? Colors.white : Colors.black87,
), ),
), ),
], if (group.description != null &&
group.description!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
group.description!,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w400,
color: isDark
? Colors.white54
: Colors.grey.shade500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
), ),
), if (!inSelectMode)
if (!inSelectMode) Icon(Icons.chevron_right, color: Colors.grey.shade400),
Icon(Icons.chevron_right, color: Colors.grey.shade400), ],
], ),
), ),
), ),
), ),

View File

@@ -1,4 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:qpay/config/app_config.dart';
import 'package:qpay/http/http.dart'; import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart'; import 'package:qpay/models/api_response.dart';
import 'package:qpay/models/pageable_model.dart'; import 'package:qpay/models/pageable_model.dart';
@@ -66,7 +69,7 @@ class HistoryController extends ChangeNotifier {
} }
_safeNotify(); _safeNotify();
params['partyType'] = 'INDIVIDUAL'; params['partyType'] = 'INDIVIDUAL,GROUP';
try { try {
Map<String, dynamic> response = await http.get( Map<String, dynamic> response = await http.get(
@@ -107,27 +110,22 @@ class HistoryController extends ChangeNotifier {
} }
Future<void> searchTransactions( Future<void> searchTransactions(
String userId, { String workspaceId, {
String? search, String? billName,
String? status, String? status,
String? type, String? type,
}) async { }) async {
model.searchQuery = search ?? ''; model.searchQuery = billName ?? '';
model.statusFilter = status; model.statusFilter = status;
model.typeFilter = type; model.typeFilter = type;
final params = <String, String>{ final params = <String, String>{
'userId': userId, 'workspaceId': workspaceId,
'page': '0', 'page': '0',
'size': '20', 'size': '20',
'sort': 'createdAt,desc', 'sort': 'createdAt,desc',
}; };
if (search != null && search.isNotEmpty) { if (billName != null && billName.isNotEmpty) {
params['creditAccount'] = search; params['billName'] = billName;
params['creditEmail'] = search;
params['creditPhone'] = search;
params['creditName'] = search;
params['billName'] = search;
params['amount'] = search;
} }
if (status != null && status.isNotEmpty) { if (status != null && status.isNotEmpty) {
params['status'] = status; params['status'] = status;
@@ -138,23 +136,18 @@ class HistoryController extends ChangeNotifier {
await getTransactions(params); await getTransactions(params);
} }
Future<void> loadNextPage(String userId) async { Future<void> loadNextPage(String workspaceId) async {
if (model.isLoadingMore || model.currentPage + 1 >= model.totalPages) { if (model.isLoadingMore || model.currentPage + 1 >= model.totalPages) {
return; return;
} }
final params = <String, String>{ final params = <String, String>{
'userId': userId, 'workspaceId': workspaceId,
'page': (model.currentPage + 1).toString(), 'page': (model.currentPage + 1).toString(),
'size': '20', 'size': '20',
'sort': 'createdAt,desc', 'sort': 'createdAt,desc',
}; };
if (model.searchQuery.isNotEmpty) { if (model.searchQuery.isNotEmpty) {
params['creditAccount'] = model.searchQuery;
params['creditEmail'] = model.searchQuery;
params['creditPhone'] = model.searchQuery;
params['creditName'] = model.searchQuery;
params['billName'] = model.searchQuery; params['billName'] = model.searchQuery;
params['amount'] = model.searchQuery;
} }
if (model.statusFilter != null && model.statusFilter!.isNotEmpty) { if (model.statusFilter != null && model.statusFilter!.isNotEmpty) {
params['status'] = model.statusFilter!; params['status'] = model.statusFilter!;
@@ -193,6 +186,58 @@ class HistoryController extends ChangeNotifier {
_safeNotify(); _safeNotify();
} }
/// Builds the fully-qualified download URL for the transactions report.
Uri _buildReportUrl({
required String workspaceId,
required DateTime startDate,
required DateTime endDate,
}) {
final df = DateFormat("yyyy-MM-dd'T'HH:mm:ss");
final start = Uri.encodeComponent(
df.format(
DateTime(startDate.year, startDate.month, startDate.day, 0, 0, 0),
),
);
final end = Uri.encodeComponent(
df.format(
DateTime(endDate.year, endDate.month, endDate.day, 23, 59, 59),
),
);
return Uri.parse(
'${AppConfig.baseUrl}/public/reports/transactions/download'
'?startDate=$start'
'&endDate=$end'
'&workspaceId=$workspaceId',
);
}
/// Opens the transactions report download URL in the device browser.
/// The browser handles the actual file download natively — no file I/O
/// or plugin channels required, works on Android, iOS, and web.
Future<void> downloadReport({
required String workspaceId,
required DateTime startDate,
required DateTime endDate,
}) async {
model.isActing = true;
_safeNotify();
try {
final uri = _buildReportUrl(
workspaceId: workspaceId,
startDate: startDate,
endDate: endDate,
);
await launchUrl(uri, mode: LaunchMode.externalApplication);
} catch (e) {
logger.e('Failed to open report URL: $e');
model.errorMessage = 'Failed to download report';
} finally {
model.isActing = false;
_safeNotify();
}
}
void clearFilters() { void clearFilters() {
model.statusFilter = null; model.statusFilter = null;
model.typeFilter = null; model.typeFilter = null;

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:qpay/auth_state.dart';
import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/receipt/receipt_controller.dart'; import 'package:qpay/screens/receipt/receipt_controller.dart';
import 'package:qpay/screens/transaction_controller.dart'; import 'package:qpay/screens/transaction_controller.dart';
@@ -64,16 +66,16 @@ class _HistoryScreenState extends State<HistoryScreen>
Future<void> setupData() async { Future<void> setupData() async {
prefs = await SharedPreferences.getInstance(); prefs = await SharedPreferences.getInstance();
final userId = prefs.getString("userId") ?? ''; final workspaceId = prefs.getString("workspaceId") ?? '';
await controller.searchTransactions(userId); await controller.searchTransactions(workspaceId);
} }
void _onScroll() { void _onScroll() {
if (_scrollController.position.pixels >= if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) { _scrollController.position.maxScrollExtent - 200) {
final userId = prefs.getString("userId") ?? ''; final workspaceId = prefs.getString("workspaceId") ?? '';
if (userId.isNotEmpty) { if (workspaceId.isNotEmpty) {
controller.loadNextPage(userId); controller.loadNextPage(workspaceId);
} }
} }
} }
@@ -227,10 +229,11 @@ class _HistoryScreenState extends State<HistoryScreen>
child: ElevatedButton( child: ElevatedButton(
onPressed: () { onPressed: () {
Navigator.of(ctx).pop(); Navigator.of(ctx).pop();
final userId = prefs.getString("userId") ?? ''; final workspaceId =
prefs.getString("workspaceId") ?? '';
controller.searchTransactions( controller.searchTransactions(
userId, workspaceId,
search: _searchController.text.isNotEmpty billName: _searchController.text.isNotEmpty
? _searchController.text ? _searchController.text
: null, : null,
status: controller.model.statusFilter, status: controller.model.statusFilter,
@@ -250,6 +253,142 @@ class _HistoryScreenState extends State<HistoryScreen>
); );
} }
/// Shows the report download bottom sheet with start/end date pickers.
void _showReportSheet() {
DateTime selectedStart = DateTime.now().subtract(const Duration(days: 7));
DateTime selectedEnd = DateTime.now();
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) {
return StatefulBuilder(
builder: (context, setSheetState) {
return Padding(
padding: EdgeInsets.only(
left: 20,
right: 20,
top: 20,
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Download Report',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
const SizedBox(height: 20),
_buildDateField(
label: 'Start Date',
date: selectedStart,
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: selectedStart,
firstDate: DateTime(2020),
lastDate: selectedEnd,
);
if (picked != null) {
setSheetState(() => selectedStart = picked);
}
},
),
const SizedBox(height: 16),
_buildDateField(
label: 'End Date',
date: selectedEnd,
onTap: () async {
final picked = await showDatePicker(
context: context,
initialDate: selectedEnd,
firstDate: selectedStart,
lastDate: DateTime.now(),
);
if (picked != null) {
setSheetState(() => selectedEnd = picked);
}
},
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: controller.model.isActing
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Icon(Icons.download),
label: Text(
controller.model.isActing
? 'Downloading…'
: 'Download Report',
),
onPressed: controller.model.isActing
? null
: () {
Navigator.of(ctx).pop();
final workspaceId =
prefs.getString("workspaceId") ?? '';
controller.downloadReport(
workspaceId: workspaceId,
startDate: selectedStart,
endDate: selectedEnd,
);
},
),
),
const SizedBox(height: 8),
],
),
);
},
);
},
);
}
/// Builds a tappable date field for the report sheet.
Widget _buildDateField({
required String label,
required DateTime date,
required VoidCallback onTap,
}) {
final df = DateFormat('dd MMM yyyy');
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Expanded(
child: Text(
'$label: ${df.format(date)}',
style: const TextStyle(fontSize: 15),
),
),
Icon(Icons.calendar_today, size: 20, color: Colors.grey.shade600),
],
),
),
);
}
@override @override
void dispose() { void dispose() {
_searchController.dispose(); _searchController.dispose();
@@ -327,9 +466,10 @@ class _HistoryScreenState extends State<HistoryScreen>
icon: const Icon(Icons.clear), icon: const Icon(Icons.clear),
onPressed: () { onPressed: () {
_searchController.clear(); _searchController.clear();
final userId = prefs.getString("userId") ?? ''; final workspaceId =
prefs.getString("workspaceId") ?? '';
controller.searchTransactions( controller.searchTransactions(
userId, workspaceId,
status: controller.model.statusFilter, status: controller.model.statusFilter,
type: controller.model.typeFilter, type: controller.model.typeFilter,
); );
@@ -351,10 +491,10 @@ class _HistoryScreenState extends State<HistoryScreen>
), ),
textInputAction: TextInputAction.search, textInputAction: TextInputAction.search,
onFieldSubmitted: (value) { onFieldSubmitted: (value) {
final userId = prefs.getString("userId") ?? ''; final workspaceId = prefs.getString("workspaceId") ?? '';
controller.searchTransactions( controller.searchTransactions(
userId, workspaceId,
search: value.isNotEmpty ? value : null, billName: value.isNotEmpty ? value : null,
status: controller.model.statusFilter, status: controller.model.statusFilter,
type: controller.model.typeFilter, type: controller.model.typeFilter,
); );
@@ -394,6 +534,15 @@ class _HistoryScreenState extends State<HistoryScreen>
}, },
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
// Report download — only visible to logged-in users.
if (authState.isLoggedIn)
_buildIconButton(
icon: Icons.description_outlined,
tooltip: 'Download report',
isActive: false,
onPressed: _showReportSheet,
),
const SizedBox(width: 8),
_buildIconButton( _buildIconButton(
icon: Icons.add, icon: Icons.add,
tooltip: 'New payment', tooltip: 'New payment',
@@ -496,10 +645,10 @@ class _HistoryScreenState extends State<HistoryScreen>
'Status: ${controller.model.statusFilter}', 'Status: ${controller.model.statusFilter}',
() { () {
controller.model.statusFilter = null; controller.model.statusFilter = null;
final userId = prefs.getString("userId") ?? ''; final workspaceId = prefs.getString("workspaceId") ?? '';
controller.searchTransactions( controller.searchTransactions(
userId, workspaceId,
search: _searchController.text.isNotEmpty billName: _searchController.text.isNotEmpty
? _searchController.text ? _searchController.text
: null, : null,
type: controller.model.typeFilter, type: controller.model.typeFilter,
@@ -509,10 +658,10 @@ class _HistoryScreenState extends State<HistoryScreen>
if (controller.model.typeFilter != null) if (controller.model.typeFilter != null)
_activeFilterChip('Type: ${controller.model.typeFilter}', () { _activeFilterChip('Type: ${controller.model.typeFilter}', () {
controller.model.typeFilter = null; controller.model.typeFilter = null;
final userId = prefs.getString("userId") ?? ''; final workspaceId = prefs.getString("workspaceId") ?? '';
controller.searchTransactions( controller.searchTransactions(
userId, workspaceId,
search: _searchController.text.isNotEmpty billName: _searchController.text.isNotEmpty
? _searchController.text ? _searchController.text
: null, : null,
status: controller.model.statusFilter, status: controller.model.statusFilter,

View File

@@ -148,14 +148,14 @@ class HomeController extends ChangeNotifier {
} }
Future<ApiResponse<List<Map<String, dynamic>>>> getTransactions( Future<ApiResponse<List<Map<String, dynamic>>>> getTransactions(
String userId, String workspaceId,
) async { ) async {
model.isLoading = true; model.isLoading = true;
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
try { try {
Map<String, dynamic> response = await http.get( Map<String, dynamic> response = await http.get(
'/public/transaction?sort=createdAt,desc&size=3&page=0&partyType=INDIVIDUAL&userId=$userId', '/public/transaction?sort=createdAt,desc&size=3&page=0&partyType=INDIVIDUAL,GROUP&workspaceId=$workspaceId',
); );
PageableModel pageableModel = PageableModel.fromJson(response); PageableModel pageableModel = PageableModel.fromJson(response);

View File

@@ -1,3 +1,4 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -8,7 +9,6 @@ import 'package:qpay/screens/home/home_controller.dart';
import 'package:qpay/screens/receipt/receipt_controller.dart'; import 'package:qpay/screens/receipt/receipt_controller.dart';
import 'package:qpay/screens/transaction_controller.dart'; import 'package:qpay/screens/transaction_controller.dart';
import 'package:qpay/screens/transactions/transaction_model.dart' as txn; import 'package:qpay/screens/transactions/transaction_model.dart' as txn;
import 'package:qpay/widgets/app_snack_bar.dart';
import 'package:qpay/widgets/transaction_item_widget.dart'; import 'package:qpay/widgets/transaction_item_widget.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -28,8 +28,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
late final router = GoRouter.of(context); late final router = GoRouter.of(context);
bool _isLoggedIn = false; bool _isLoggedIn = false;
bool _isZWGSelected = false;
String initials = ''; String initials = '';
String _selectedCurrency = 'USD';
/// Incremented to force [AccountBalanceWidget] to recreate and re-read
/// auth state from disk (e.g. after logout or manual refresh).
int _balanceWidgetRefreshKey = 0;
late AnimationController _providersAnimController; late AnimationController _providersAnimController;
late Animation<Offset> _providersSlideAnimation; late Animation<Offset> _providersSlideAnimation;
@@ -107,32 +111,55 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
homeController = HomeController(context); homeController = HomeController(context);
await homeController.getAccounts(); await homeController.getAccounts();
await homeController.getCategories(); await homeController.getCategories();
await homeController.getProviders(_isZWGSelected ? "ZWG" : "USD"); await homeController.getProviders(_selectedCurrency);
transactionController.model.formData = transactionController.model.formData
.copyWith(debitCurrency: _selectedCurrency);
prefs = await SharedPreferences.getInstance(); prefs = await SharedPreferences.getInstance();
if (!mounted) return;
if (prefs.getString("token") != null) { if (prefs.getString("token") != null) {
setState(() { setState(() {
_isLoggedIn = true; _isLoggedIn = true;
initials = prefs.getString("initials")!; initials = prefs.getString("workspaceInitials")!;
}); });
} }
if (prefs.getString("workspaceId") == null) {
prefs.setString("workspaceId", Uuid().v4());
}
if (prefs.getString("userId") == null) { if (prefs.getString("userId") == null) {
prefs.setString("userId", Uuid().v4()); prefs.setString("userId", Uuid().v4());
} }
await homeController.getTransactions(prefs.getString("userId")!); await homeController.getTransactions(prefs.getString("workspaceId")!);
}
void _onCurrencySelected(String currency) {
if (_selectedCurrency == currency) return;
setState(() {
_selectedCurrency = currency;
});
homeController.getProviders(currency);
transactionController.updateCurrency(currency);
} }
Future<void> _onRefresh() async { Future<void> _onRefresh() async {
setState(() {
_balanceWidgetRefreshKey++;
});
await homeController.getAccounts(); await homeController.getAccounts();
await homeController.getCategories(); await homeController.getCategories();
await homeController.getProviders(_isZWGSelected ? "ZWG" : "USD"); await homeController.getProviders(_selectedCurrency);
prefs = await SharedPreferences.getInstance(); prefs = await SharedPreferences.getInstance();
if (prefs.getString("userId") != null) { if (prefs.getString("workspaceId") != null) {
await homeController.getTransactions(prefs.getString("userId")!); await homeController.getTransactions(prefs.getString("workspaceId")!);
} }
} }
@@ -141,19 +168,34 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
if (location.startsWith('/home')) { if (location.startsWith('/home')) {
prefs = await SharedPreferences.getInstance(); prefs = await SharedPreferences.getInstance();
if (prefs.getString("workspaceId") == null) {
prefs.setString("workspaceId", Uuid().v4());
}
if (prefs.getString("userId") == null) { if (prefs.getString("userId") == null) {
prefs.setString("userId", Uuid().v4()); prefs.setString("userId", Uuid().v4());
} }
await homeController.getTransactions(prefs.getString("userId")!); await homeController.getTransactions(prefs.getString("workspaceId")!);
} }
} }
/// Handles the repeat action for a transaction. /// Handles the repeat action for a transaction.
Future<void> _repeatTransaction(Map<String, dynamic> item) async { Future<void> _repeatTransaction(Map<String, dynamic> item) async {
final receiptController = ReceiptController(context); try {
final transaction = txn.TransactionModel.fromJson(item); final receiptController = ReceiptController(context);
await receiptController.repeatTransaction(transaction); final transaction = txn.TransactionModel.fromJson(item);
await receiptController.repeatTransaction(transaction);
} catch (e, s) {
if (kDebugMode) {
print(e);
print(s);
}
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Error repeating transaction')));
}
}
if (mounted) { if (mounted) {
context.push('/make-payment'); context.push('/make-payment');
} }
@@ -311,24 +353,28 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
end: Alignment.bottomLeft, end: Alignment.bottomLeft,
), ),
), ),
child: Center( child: InkWell(
child: Text( child: Center(
initials, child: Text(
style: TextStyle( initials,
fontSize: 12, style: TextStyle(
fontWeight: FontWeight.bold, fontSize: 12,
color: isDark ? Colors.white : Colors.black87, fontWeight: FontWeight.bold,
color: isDark ? Colors.white : Colors.black87,
),
), ),
), ),
onTap: () => context.go('/workspace'),
), ),
), ),
SizedBox(width: 5),
IconButton( IconButton(
icon: Icon( icon: Icon(
Icons.logout_rounded, Icons.logout,
color: isDark ? Colors.white54 : Colors.black54, color: isDark ? Colors.white54 : Colors.black54,
size: 20, size: 20,
), ),
onPressed: _showLogoutDialog, onPressed: () => _showLogoutDialog(),
), ),
], ],
]; ];
@@ -364,130 +410,23 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (!_isLoggedIn) _buildLoginPrompt(theme, isDark), AccountBalanceWidget(
if (_isLoggedIn) const AccountBalanceWidget(), key: ValueKey(_balanceWidgetRefreshKey),
const SizedBox(height: 16), selectedCurrency: _selectedCurrency,
onCurrencySelected: _onCurrencySelected,
),
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
_buildSectionLabel(theme, isDark, Icons.grid_view_rounded, "Pay"), _buildSectionLabel(theme, isDark, Icons.grid_view_rounded, "Pay"),
Row( IconButton(
mainAxisSize: MainAxisSize.min, icon: Icon(
children: [ Icons.refresh_rounded,
Container( color: isDark ? Colors.white54 : Colors.black54,
padding: const EdgeInsets.symmetric( size: 20,
horizontal: 8, ),
vertical: 2, onPressed: _onRefresh,
), tooltip: 'Refresh',
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E1E1E) : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.12)
: Colors.black.withValues(alpha: 0.08),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'USD',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: !_isZWGSelected
? theme.colorScheme.primary
: isDark
? Colors.white54
: Colors.black45,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: GestureDetector(
onTap: () {
setState(() {
_isZWGSelected = !_isZWGSelected;
});
if (_isZWGSelected) {
AppSnackBar.show(
context,
'ZWG currency not supported yet',
customContent: const Center(
child: Text('ZWG currency not supported yet'),
),
backgroundColor: theme.colorScheme.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
duration: const Duration(seconds: 3),
);
}
Future.delayed(
const Duration(milliseconds: 1000),
() {
setState(() {
_isZWGSelected = !_isZWGSelected;
});
},
);
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 32,
height: 18,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
color: _isZWGSelected
? theme.colorScheme.primary
: Colors.grey.shade300,
),
child: AnimatedAlign(
duration: const Duration(milliseconds: 200),
alignment: _isZWGSelected
? Alignment.centerRight
: Alignment.centerLeft,
child: Container(
width: 14,
height: 14,
margin: const EdgeInsets.all(2),
decoration: const BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
),
),
),
),
),
),
Text(
'ZWG',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: _isZWGSelected
? theme.colorScheme.primary
: isDark
? Colors.white54
: Colors.black45,
),
),
],
),
),
const SizedBox(width: 4),
IconButton(
icon: Icon(
Icons.refresh_rounded,
color: isDark ? Colors.white54 : Colors.black54,
size: 20,
),
onPressed: _onRefresh,
tooltip: 'Refresh',
),
],
), ),
], ],
), ),
@@ -743,67 +682,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
); );
} }
// ──────────────────────────────────────────────────────────────
// Login Prompt
// ──────────────────────────────────────────────────────────────
Widget _buildLoginPrompt(ThemeData theme, bool isDark) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
theme.colorScheme.primary.withValues(alpha: 0.12),
theme.colorScheme.primary.withValues(alpha: 0.04),
],
),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: theme.colorScheme.primary.withValues(alpha: 0.15),
width: 1,
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Register or Login for more features',
style: TextStyle(
fontSize: 14,
color: isDark ? Colors.white : Colors.black87,
),
),
],
),
),
ElevatedButton(
onPressed: () {
context.push('/onboarding/landing');
},
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
child: const Text(
'Get Started',
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
),
],
),
);
}
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
// Logout Dialog // Logout Dialog
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
@@ -831,6 +709,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
prefs.clear(); prefs.clear();
setState(() { setState(() {
_isLoggedIn = false; _isLoggedIn = false;
_balanceWidgetRefreshKey++;
}); });
authState.refresh(); authState.refresh();
setupDataAndTransitions(); setupDataAndTransitions();

View File

@@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../../auth_state.dart';
import '../../models/responsive_policy.dart';
class MoreScreen extends StatefulWidget {
const MoreScreen({super.key});
@override
State<MoreScreen> createState() => _MoreScreenState();
}
class _MoreScreenState extends State<MoreScreen> {
late SharedPreferences _prefs;
@override
void initState() {
super.initState();
_initPrefs();
}
Future<void> _initPrefs() async {
_prefs = await SharedPreferences.getInstance();
}
void _showLogoutDialog() {
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: const Text('Confirm Logout'),
content: const Text('Are you sure you want to log out?'),
actions: <Widget>[
TextButton(
child: const Text('Cancel'),
onPressed: () {
Navigator.of(dialogContext).pop();
},
),
TextButton(
child: const Text('Logout'),
onPressed: () {
Navigator.of(dialogContext).pop();
_prefs.clear();
authState.refresh();
context.go("/");
},
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('More'), centerTitle: false),
body: Center(
child: LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
? 600.0
: double.infinity;
return SizedBox(
width: maxWidth,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_NavOptionTile(
icon: Icons.swap_horiz,
title: 'Change Workspace',
subtitle: 'Switch to a different workspace',
onTap: () => context.go('/workspace'),
),
_NavOptionTile(
icon: Icons.people_outline,
title: 'Users',
subtitle: 'Manage users and permissions',
onTap: () => context.go('/users'),
),
_NavOptionTile(
icon: Icons.monitor_heart_outlined,
title: 'Uptime Status',
subtitle: 'View system uptime and service status',
onTap: () => context.push('/uptime'),
),
_NavOptionTile(
icon: Icons.support_agent_rounded,
title: 'Contact Us',
subtitle: 'Get in touch with our team',
onTap: () => context.push('/contact'),
),
_NavOptionTile(
icon: Icons.logout,
title: 'Logout',
subtitle: 'Sign out of your account',
onTap: () => _showLogoutDialog(),
),
],
),
);
},
),
),
);
}
}
class _NavOptionTile extends StatelessWidget {
const _NavOptionTile({
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
leading: Icon(icon, size: 28, color: theme.colorScheme.primary),
title: Text(title, style: theme.textTheme.titleMedium),
subtitle: Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(subtitle, style: theme.textTheme.bodySmall),
),
trailing: Icon(
Icons.chevron_right,
color: theme.colorScheme.onSurface.withAlpha(100),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onTap: onTap,
);
}
}

View File

@@ -0,0 +1,18 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../../models/user_model.dart';
class AuthProvider {
// get the user model from shared preferences
static Future<UserModel?> getUserModel() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
String? userModelString = prefs.getString("userModel");
if (userModelString != null) {
Map<String, dynamic> userModelJson = jsonDecode(userModelString);
return UserModel.fromJson(userModelJson);
}
return null;
}
}

View File

@@ -102,10 +102,10 @@ class _LandingScreenState extends State<LandingScreen> {
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Center( Center(
child: TextButton( child: TextButton(
onPressed: () { onPressed: () {
context.go('/'); context.go('/workspace');
}, },
child: Text( child: Text(
'Continue as guest?', 'Continue as guest?',
style: TextStyle( style: TextStyle(

View File

@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:qpay/auth_state.dart'; import 'package:qpay/auth_state.dart';
@@ -5,6 +7,8 @@ import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart'; import 'package:qpay/models/api_response.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import '../../../models/user_model.dart';
class LoginScreenModel { class LoginScreenModel {
String username = ""; String username = "";
String password = ""; String password = "";
@@ -72,6 +76,9 @@ class LoginController extends ChangeNotifier {
: ''), : ''),
); );
UserModel userModel = UserModel.fromJson(response);
prefs.setString("userModel", jsonEncode(userModel));
// Notify the router that the user is now signed in so any // Notify the router that the user is now signed in so any
// auth-gated redirects (e.g. the groups flow) are re-evaluated // auth-gated redirects (e.g. the groups flow) are re-evaluated
// and the user is sent to their originally requested page. // and the user is sent to their originally requested page.

Some files were not shown because too many files have changed in this diff Show More