Compare commits

43 Commits

Author SHA1 Message Date
ea04dea7bc improving splash 2026-07-12 17:57:22 +02:00
f04fc15fb9 improving error message on batch item processing 2026-07-01 13:46:52 +02:00
dad09202c4 filter wallet payment option when user nto logged in 2026-06-29 17:47:23 +02:00
c253a69566 updating poll duration 2026-06-29 16:57:39 +02:00
eed7415ef3 update build number 2026-06-29 16:03:59 +02:00
1db6641f2c improving VMC flow 2026-06-29 15:23:46 +02:00
55921f6ee3 improving VMC flow 2026-06-29 15:10:37 +02:00
17db608dbc adding delete url 2026-06-26 14:13:01 +02:00
eb774b72f6 adding delete url 2026-06-26 13:56:17 +02:00
a1a7f3a4d6 nginx fixes 2026-06-25 02:10:59 +02:00
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
158 changed files with 12791 additions and 3574 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

@@ -40,7 +40,8 @@ flutter run --dart-define=APP_ENV=live
```bash ```bash
flutter build web --dart-define=APP_ENV=live flutter build web --dart-define=APP_ENV=live
flutter build apk --dart-define=APP_ENV=live flutter build apk --dart-define=APP_ENV=live --dart-define=BASE_URL=https://payapi.velocityafrica.net/api
flutter build appbundle --dart-define-from-file=config.json
``` ```
### Quick pre-release checks ### Quick pre-release checks

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

View File

@@ -1,18 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen --> <!-- Remove this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" /> <item android:drawable="@color/splash_background" />
<!-- App Logo -->
<item
android:width="100dp"
android:height="100dp"
android:gravity="center">
<bitmap
android:gravity="fill"
android:src="@mipmap/ic_launcher"
android:mipMap="true"/>
</item>
</layer-list> </layer-list>

View File

@@ -1,17 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen --> <!-- Remove this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/splash_background" /> <item android:drawable="@color/splash_background" />
<!-- App Logo -->
<item
android:width="100dp"
android:height="100dp"
android:gravity="center">
<bitmap
android:gravity="fill"
android:src="@mipmap/ic_launcher"
android:mipMap="true"/>
</item>
</layer-list> </layer-list>

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

4
config.json Normal file
View File

@@ -0,0 +1,4 @@
{
"APP_ENV": "live",
"BASE_URL": "https://payapi.velocityafrica.net/api"
}

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

317
docs/PRODUCT_SPEC.md Normal file
View File

@@ -0,0 +1,317 @@
# Velocity Pay — Product Specification
**Version:** 1.0.1
**Last Updated:** January 2026
**Product Owner:** Velocity Africa (Qantra)
---
## 1. Executive Summary
Velocity Pay is a multi-platform bill-payment application that enables individuals and businesses in Zimbabwe to pay utilities, purchase airtime, and send batch payments through a single, streamlined experience. The application supports EcoCash, Mastercard/Visa (MPGS), and wallet-based payment processors, and is available across Android, iOS, and the web.
Built with Flutter, Velocity Pay delivers a consistent, high-quality user experience on mobile devices and desktop browsers alike. It is designed from the ground up to support multi-workspace / multi-tenant scenarios, making it suitable for both individual consumers and organisations that manage multiple business entities.
---
## 2. Target Audience
| Segment | Description |
|---------|-------------|
| **Individual consumers** | Pay utility bills (ZESA electricity, water), purchase airtime (Econet, NetOne, Telecel), and top up mobile wallets. |
| **Small businesses** | Manage payments across a single workspace with history tracking and repeat transactions. |
| **Enterprises / NGOs** | Use batch payments via recipient **Groups** to disburse funds to many recipients at once (e.g. payroll, beneficiary disbursements). |
| **Developers / Integrators** | Organisations that need to embed payment functionality into their own systems can reference Velocity Pay's gateway integration patterns. |
---
## 3. Supported Platforms
| Platform | Status | Notes |
|----------|--------|-------|
| **Android** | GA | Native APK / App Bundle distribution via Google Play. |
| **iOS** | GA | Native IPA distribution via the App Store. |
| **Web** | GA | Progressive Web App (PWA) served via Nginx. Works on all modern browsers (Chrome, Firefox, Safari, Edge). |
| **Linux / macOS / Windows** | Beta | Desktop builds available for internal/admin use. |
---
## 4. Core Feature Set
### 4.1 Onboarding & Authentication
- **Phone number sign-in:** Users enter their mobile number, receive an OTP verification code, and authenticate.
- **Google Sign-In:** OAuth-based authentication for web users (desktop and mobile web).
- **Persisted sessions:** Tokens are stored securely on-device so users remain signed in across cold starts.
- **Splash animation:** A branded loading animation plays on cold start before the app routes to the appropriate screen.
### 4.2 Workspaces
- After authentication, users are presented with a workspace selector if they belong to multiple organisations.
- A workspace bundles a user's billing accounts, transaction history, and groups under one organisational context.
- Single-workspace users are auto-routed directly to the home screen.
- Guest (unauthenticated) users receive a transient workspace UUID so they can still make one-off payments.
### 4.3 Home Dashboard
- **Account balance widget:** Displays the current balance for the authenticated user's workspace. Includes a currency selector (USD, ZWG, etc.).
- **Bill provider directory:** A dynamic, API-driven grid of available billers (Econet, NetOne, ZESA, Telecel, etc.). Each card shows the provider's logo, name, and description.
- **Recent activity feed:** A chronological list of the user's most recent transactions with status chips (Success / Pending / Failed). Each transaction includes a **Repeat** action that pre-fills the payment flow with the same details.
- **Pull-to-refresh:** Manually reload balances, providers, and transactions.
- **Responsive layout:** On mobile the UI uses a full-bleed SliverAppBar and bottom NavigationBar; on tablets and desktop it uses a side NavigationRail.
### 4.4 Payment Flow
The payment flow is a multi-step wizard with the following stages:
| Step | Screen | Description |
|------|--------|-------------|
| 1 | **Home → Provider Select** | User taps a bill provider (e.g. Econet, ZESA). |
| 2 | **Recipients** | Choose a saved contact, enter a phone number manually, or pick from the device's native contacts. |
| 3 | **Product Selection** (conditional) | If the provider offers multiple products (e.g. airtime bundles, electricity tokens), the user picks the specific product and amount. |
| 4 | **Pay** | Enter payment amount (when not fixed by product), select the payment processor (EcoCash / MPGS / Wallet), enter the debit phone number, and optionally record the recipient's name and email for a receipt. |
| 5 | **Confirm** | Review all transaction details before submission. The user sees a line-item breakdown: amount, charge, gateway charge, tax, and total. |
| 6 | **Gateway** | The app opens the payment processor's checkout page (hosted WebView for Mastercard, or redirect for mobile wallets). The user completes payment authorisation. |
| 7 | **Polling** | The app polls the backend for the transaction result for up to 30 attempts (~90 seconds). Progress is indicated by a step counter and status text. |
| 8 | **Integration** | On success, the backend posts the payment to the billing provider (e.g. credits the electricity meter). A loading indicator shows this progress. |
| 9 | **Receipt** | A full transaction receipt is displayed with share capabilities (image export via `share_plus`). The receipt includes transaction ID, date, amount, charges, provider, and status. |
**Error handling at every step:** Network errors, failed payments, and integration failures are surfaced with human-readable messages and a **Retry** button. Users can cancel polling at any time.
### 4.5 Payment Processors
| Processor | Label | Description |
|-----------|-------|-------------|
| **EcoCash** | `ECOCASH` | Zimbabwe's mobile money wallet. Authorisation via USSD push or in-app prompt. |
| **Mastercard Payment Gateway Services** | `MPGS` | Card payments (Visa, Mastercard). Hosted checkout page in a WebView. |
| **Wallet** | `WALLET` | In-app wallet balance (available only to authenticated users with a funded workspace wallet). |
### 4.6 Transaction History
- Full, searchable history of all past transactions for the current workspace.
- Each transaction item displays: provider icon, provider name, amount, date (relative time), and a coloured status chip.
- **Repeat transaction:** One-tap repeat that pre-fills the payment flow with the same provider, product, amount, and payment processor.
- **Receipt view:** Tap any historical transaction to view its full receipt.
### 4.7 Groups & Batch Payments
Groups allow users to organise recipients and send batch payments efficiently.
- **Group creation:**
- **Manual form:** Enter a group name, description, and add recipients one at a time.
- **CSV file import:** Upload a CSV file containing recipient names, phone numbers, amounts, and email addresses. A sample CSV template is provided.
- **Group management:** View all groups, search by name, select multiple groups for bulk deletion.
- **Batch creation:** Within a group, create a payment batch that processes all recipients. Each batch is a discrete payment run with its own status.
- **Batch detail:** View the status of each item (recipient) in a batch. Individual batch items can be inspected for success/failure details.
### 4.8 Users & Permissions
- Authenticated workspace admins can view and manage the list of users associated with their workspace (accessible from the **More** menu).
- User listing is available at `/users` and is protected behind an auth guard.
### 4.9 Uptime & Service Status
- A real-time service status dashboard showing the operational state of every integrated bill provider and payment processor.
- Each service displays:
- Service name and icon (EcoCash, Econet, ZESA, etc.)
- Up/Down status indicator (green/red chip)
- Last-checked timestamp (relative time)
- Summary bar shows total counts of Up vs Down services.
- Data is fetched on-screen load and supports pull-to-refresh.
### 4.10 Profile & Account Management
- **Profile:** View account details (accessible from `/profile`).
- **Change Workspace:** Switch to a different workspace without logging out.
- **Logout:** Clears local session and returns to the landing screen.
- **Account Deletion:** GDPR/Play Store compliant account deletion request flow at `/delete-account`.
### 4.11 Contact & Support
- Dedicated contact screen accessible from the **More** menu.
- Footer links in the web navigation rail provide quick access to About, FAQs, Privacy Policy, and Terms of Service on the Velocity Africa website.
---
## 5. Architecture & Technology
| Layer | Technology |
|-------|-----------|
| **Framework** | Flutter (Dart 3.9+) |
| **State Management** | Provider + ChangeNotifier (with Freezed immutable models) |
| **Routing** | GoRouter (declarative, path-based, with auth guards) |
| **HTTP Client** | Dio |
| **Code Generation** | Freezed + json_serializable + build_runner |
| **Local Storage** | SharedPreferences (token, workspace ID, user preferences) |
| **Backend** | Velocity Pay API (`payapi.velocityafrica.net/api`) |
| **Auth** | OAuth 2.0 (Google) + Phone OTP |
| **Payment Gateway** | Mastercard MPGS (hosted checkout via WebView + JavaScript) |
| **Push Notifications** | Firebase Cloud Messaging |
| **Analytics** | Firebase |
| **Deployment** | Docker + Nginx (web), Google Play (Android), App Store (iOS) |
---
## 6. Environment Configuration
Velocity Pay supports compile-time environment switching via `--dart-define` flags:
| Flag | Values | Description |
|------|--------|-------------|
| `APP_ENV` | `test`, `live` | Selects the runtime environment. Defaults to `test`. |
| `BASE_URL` | Any URL | Overrides the backend API base URL. |
| `CLIENTID` | String | Google OAuth web client ID. |
| `SIMULATE_PAYMENT_SUCCESS` | `true`, `false` | When `true`, the gateway flow returns a simulated success (development only). |
| `GATEWAY_SCRIPT_URL` | URL | Mastercard checkout JavaScript bundle URL. |
**Pre-release checklist:**
- `assets/.env.live` points to the live API.
- `SIMULATE_PAYMENT_SUCCESS` is set to `false`.
- `GATEWAY_SCRIPT_URL` uses the production Mastercard script.
---
## 7. Navigation Structure
### Authenticated Shell (with nav bar / nav rail)
```
/home → Home dashboard (providers + recent activity)
/groups → Group listing
/groups/create → Create group (manual form)
/groups/create-from-file → Create group (CSV upload)
/groups/:groupId → Group detail
/groups/:groupId/batches/create → Create batch for group
/groups/batches/:batchId → Batch detail
/history → Full transaction history
/more → More menu (workspace switch, users, uptime, contact, logout)
/users → User management
/uptime → Service uptime status
/profile → User profile
/contact → Contact support
/workspace → Workspace selector
```
### Payment Flow (no nav in shell — full-screen wizard)
```
/recipients → Recipient selection
/make-payment → Payment form (amount, processor)
/confirm → Transaction confirmation
/gateway → Payment gateway (native WebView)
/gateway-web → Payment gateway (web)
/gateway-redirect → Gateway redirect handler
/poll → Transaction status polling
/poll/:id → Poll for specific transaction
/integration → Post-payment billing integration
/receipt → Transaction receipt
/receipt/:transactionId → Receipt for specific transaction
```
### Onboarding (full-screen, no shell)
```
/onboarding/landing → Welcome landing page
/onboarding/phone → Phone number entry
/onboarding/verify → OTP verification
/onboarding/login → Sign-in (Google OAuth for web)
/onboarding/complete → Onboarding complete / redirect
```
### Public
```
/splash → Animated splash screen
/delete-account → GDPR account deletion request
```
---
## 8. Key User Journeys
### 8.1 First-Time User — Pay Airtime
1. Opens app → splash animation plays.
2. (Guest) Assigned a transient workspace → lands on Home.
3. Taps **Econet** from the provider grid.
4. Enters recipient's Econet phone number.
5. Selects an airtime bundle product (e.g. $5 Airtime).
6. Chooses **EcoCash** as the payment processor.
7. Enters their own EcoCash number (the debit phone).
8. Reviews the confirmation screen: amount, charges, total.
9. Confirms → Gateway opens → approves the EcoCash prompt.
10. App polls for status → "Success".
11. Billing integration updates the airtime → Receipt displayed.
12. Taps **Share** to send receipt via WhatsApp.
### 8.2 Returning User — Repeat Transaction
1. Opens app → session is persisted → lands on Home.
2. Scrolls to **Recent Activity**.
3. Finds a past Econet airtime transaction, taps **Repeat**.
4. All fields are pre-filled → confirms without re-entering data.
5. Completes the gateway flow → Receipt shown.
### 8.3 Business User — Batch Payment
1. Signs in with Google → selects their organisation workspace.
2. Navigates to **Groups** via the nav bar.
3. Taps **+** → **Create from File** → uploads a CSV of 50 recipients.
4. Opens the group → taps **Create Batch**.
5. Reviews the batch summary (50 items, total amount).
6. Confirms → batch is submitted → each item processed individually.
7. Monitors batch progress on the batch detail screen.
8. All successes / failures are visible per item.
---
## 9. Responsive Design & Accessibility
- **Mobile (< 768px):** Bottom NavigationBar, single-column layout, SliverAppBar with the Velocity logo.
- **Tablet / Desktop (≥ 768px):** Side NavigationRail (256px wide, always visible), constrained content width (600px max), footer links with About/FAQs/Privacy/Terms.
- All interactive elements meet minimum touch target sizes (48×48dp).
- Skeleton loading placeholders are used during data fetches to reduce perceived latency.
---
## 10. Security & Compliance
- All API communication is over HTTPS.
- Auth tokens are persisted in platform-secure local storage.
- The Google OAuth client ID is a public value no secrets are embedded in client bundles.
- Payment gateway integration uses Mastercard's hosted checkout; Velocity Pay never handles raw card numbers.
- GDPR compliance: Users can request account deletion via a dedicated in-app flow.
- Environment configuration uses compile-time flags rather than runtime `.env` files, preventing secrets from leaking into web bundles.
---
## 11. Upcoming / Roadmap
| Feature | Status | Target |
|---------|--------|--------|
| Push notifications for transaction status | Planned | Q2 2026 |
| Dark mode support | Planned | Q2 2026 |
| Multi-currency wallet top-up | Planned | Q3 2026 |
| Desktop app distribution (App Store / Microsoft Store) | Planned | Q3 2026 |
| Offline transaction queuing | Under Review | TBD |
| ZWL (ZiG) full currency support | In Progress | Q2 2026 |
---
## 12. Glossary
| Term | Definition |
|------|-----------|
| **Bill Provider** | A utility company or service provider whose bills can be paid via Velocity Pay (e.g. ZESA, Econet). |
| **Payment Processor** | The financial rail used to move funds (EcoCash mobile money, Mastercard/Visa card, in-app Wallet). |
| **Workspace** | An organisational context that groups billing accounts, transaction history, and recipient groups. |
| **Group** | A collection of recipients for batch payment processing. |
| **Batch** | A discrete payment run executed against a group of recipients. |
| **Gateway** | The Mastercard-hosted payment checkout page where card details are entered. |
| **Polling** | The process of repeatedly checking the backend for a transaction's final status after gateway authorisation. |
| **Integration** | The backend step that posts a confirmed payment to the bill provider's system (e.g. crediting a meter). |
| **MPGS** | Mastercard Payment Gateway Services the card payment processor. |
---
*For technical setup instructions, environment configuration, and build commands, refer to the project's [README.md](../README.md).*

View File

@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"

View File

@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig" #include "Generated.xcconfig"

43
ios/Podfile Normal file
View File

@@ -0,0 +1,43 @@
# Uncomment this line to define a global platform for your project
# platform :ios, '13.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end

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

@@ -3,11 +3,7 @@ class ApiResponse<T> {
final String? error; final String? error;
final int? statusCode; final int? statusCode;
const ApiResponse({ const ApiResponse({this.data, this.error, this.statusCode});
this.data,
this.error,
this.statusCode,
});
bool get isSuccess => error == null && data != null; bool get isSuccess => error == null && data != null;
@@ -18,4 +14,4 @@ class ApiResponse<T> {
factory ApiResponse.failure(String error, {int? statusCode}) { factory ApiResponse.failure(String error, {int? statusCode}) {
return ApiResponse(error: error, statusCode: statusCode); return ApiResponse(error: error, statusCode: statusCode);
} }
} }

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,13 @@ 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';
import 'package:qpay/screens/delete_account/delete_account_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 +70,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 +141,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';
} }
} }
@@ -149,15 +157,24 @@ GoRouter buildRouter() {
path: '/splash', path: '/splash',
builder: (context, state) => const SplashScreen(), builder: (context, state) => const SplashScreen(),
), ),
// Publicly accessible account deletion screen (Play Store requirement)
GoRoute(
path: '/delete-account',
builder: (context, state) => const DeleteAccountScreen(),
),
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 +235,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 +259,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 +278,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',
); );
@@ -98,4 +146,4 @@ class AccountProvider extends ChangeNotifier {
_statementError = null; _statementError = null;
notifyListeners(); notifyListeners();
} }
} }

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,199 +301,226 @@ 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,10 +1,15 @@
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';
import 'package:url_launcher/url_launcher.dart';
import '../transactions/transaction_model.dart';
class ConfirmScreen extends StatefulWidget { class ConfirmScreen extends StatefulWidget {
const ConfirmScreen({super.key}); const ConfirmScreen({super.key});
@@ -120,7 +125,23 @@ class _ConfirmScreenState extends State<ConfirmScreen>
.authType == .authType ==
"WEB") { "WEB") {
if (kIsWeb) { if (kIsWeb) {
context.push('/gateway-web'); final txData = TransactionModel.fromJson(response.data!);
// VMC is the only method that requires redirect for now
if (txData.targetUrl != null) {
final proceed = await _showLeavingSiteBottomSheet();
if (proceed != true) return;
if (!mounted) return;
await launchUrl(
Uri.parse(txData.targetUrl!),
mode: LaunchMode
.externalApplication, // always open in external browser
);
}
// sent user to another tab and navigate to polling page to wait for response
if (!mounted) return;
context.push('/poll/${txData.id}');
} else { } else {
context.push('/gateway'); context.push('/gateway');
} }
@@ -302,6 +323,136 @@ class _ConfirmScreenState extends State<ConfirmScreen>
); );
} }
/// Shows a bottom sheet informing the user they are about to leave this site.
/// Returns `true` if the user chooses to proceed, `null` if they dismiss or cancel.
Future<bool?> _showLeavingSiteBottomSheet() async {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
return showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
),
child: Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(24),
),
),
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
),
Center(
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Icon(
Icons.security_sharp,
size: 28,
color: Colors.orange,
),
),
),
const SizedBox(height: 16),
Center(
child: Text(
'Secure Payment Stage',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 8),
Text(
'We are now redirecting you to our secure payment page. If nothing happens after the redirect, please make sure pop-ups are allowed in your browser settings.',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.grey.shade500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Navigator.of(sheetContext).pop(true);
},
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
),
child: const Text(
'Proceed',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () {
Navigator.of(sheetContext).pop(null);
},
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
child: const Text(
'Cancel',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
),
);
},
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -370,6 +521,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 +751,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

@@ -0,0 +1,541 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../models/responsive_policy.dart';
/// A publicly accessible screen that allows users to request account
/// deletion in compliance with Google Play Store Data Deletion
/// requirements.
///
/// This screen:
/// - Explains what data is deleted and retained.
/// - Provides a clear call-to-action for deletion.
/// - Includes a contact fallback for support.
class DeleteAccountScreen extends StatelessWidget {
const DeleteAccountScreen({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(
'Request Account Deletion',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 18,
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 / intro card
_buildHeaderCard(theme, isDark),
const SizedBox(height: 16),
// What happens to your data
_buildDataDeletionCard(theme, isDark),
const SizedBox(height: 16),
// Action card
_buildActionCard(theme, isDark, context),
const SizedBox(height: 16),
// Contact fallback
_buildContactCard(theme, isDark),
const SizedBox(height: 32),
],
),
);
},
),
),
);
}
Widget _buildHeaderCard(ThemeData theme, bool isDark) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 24),
decoration: _cardDecoration(theme, isDark),
child: Column(
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
color: Colors.red.shade50,
shape: BoxShape.circle,
border: Border.all(color: Colors.red.shade200, width: 1.5),
),
child: Icon(
Icons.delete_forever_rounded,
size: 36,
color: Colors.red.shade600,
),
),
const SizedBox(height: 20),
Text(
'Delete Your Account',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 8),
Text(
'We\'re sorry to see you go. Please read the information '
'below before proceeding with your deletion request.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: isDark ? Colors.white60 : Colors.grey.shade600,
height: 1.5,
),
),
],
),
);
}
Widget _buildDataDeletionCard(ThemeData theme, bool isDark) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: _cardDecoration(theme, isDark),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.info_outline_rounded,
size: 22,
color: theme.colorScheme.primary,
),
const SizedBox(width: 10),
Text(
'What happens when you delete your account?',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
),
),
],
),
const SizedBox(height: 20),
_buildInfoPoint(
isDark: isDark,
icon: Icons.person_remove_rounded,
title: 'Profile & Personal Data',
description:
'Your profile information, including your name, phone number, '
'email address, and profile picture will be permanently erased.',
),
const SizedBox(height: 14),
_buildInfoPoint(
isDark: isDark,
icon: Icons.history_rounded,
title: 'Transaction History',
description:
'All transaction records, payment history, and receipts '
'associated with your account will be deleted.',
),
const SizedBox(height: 14),
_buildInfoPoint(
isDark: isDark,
icon: Icons.group_remove_rounded,
title: 'Group & Recipient Data',
description:
'Any groups you have created and recipient data you have '
'stored will be removed from our systems.',
),
const SizedBox(height: 14),
_buildInfoPoint(
isDark: isDark,
icon: Icons.schedule_rounded,
title: 'Retention Period',
description:
'Some data may be retained for up to 90 days as required by '
'legal and regulatory obligations. After this period, all '
'remaining data will be permanently deleted.',
),
const SizedBox(height: 14),
_buildInfoPoint(
isDark: isDark,
icon: Icons.undo_rounded,
title: 'Irreversible Action',
description:
'Account deletion is permanent and cannot be undone once '
'processed. Please ensure you have exported any data you '
'wish to keep before submitting your request.',
),
],
),
);
}
Widget _buildInfoPoint({
required bool isDark,
required IconData icon,
required String title,
required String description,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(icon, size: 20, color: Colors.red.shade400),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : Colors.black87,
),
),
const SizedBox(height: 4),
Text(
description,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w400,
color: isDark ? Colors.white54 : Colors.grey.shade600,
height: 1.4,
),
),
],
),
),
],
);
}
Widget _buildActionCard(ThemeData theme, bool isDark, BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: _cardDecoration(theme, isDark),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.send_rounded,
size: 22,
color: theme.colorScheme.primary,
),
const SizedBox(width: 10),
Text(
'How to Request Deletion',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
),
),
],
),
const SizedBox(height: 16),
Text(
'To ensure the security of your account, all deletion '
'requests must be verified. Please use one of the '
'following methods to submit your request.',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: isDark ? Colors.white60 : Colors.grey.shade600,
height: 1.5,
),
),
const SizedBox(height: 20),
// Email deletion request
_buildActionButton(
context: context,
isDark: isDark,
icon: Icons.email_rounded,
iconColor: const Color(0xFFEA4335),
title: 'Request via Email',
subtitle: 'Send us a deletion request from your registered email',
onTap: () {
launchUrl(
Uri(
scheme: 'mailto',
path: 'support@velocityafrica.net',
queryParameters: {
'subject': 'Account Deletion Request',
'body':
'Please delete my Velocity account. My registered phone '
'number is: [enter your phone number]',
},
),
mode: LaunchMode.externalApplication,
);
},
),
const SizedBox(height: 12),
// Processing note
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.access_time_rounded,
size: 18,
color: isDark ? Colors.white60 : Colors.grey.shade700,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'We process deletion requests within 7 business days. '
'You will receive a confirmation email once your '
'account has been deleted.',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white54 : Colors.grey.shade600,
height: 1.4,
),
),
),
],
),
),
],
),
);
}
Widget _buildActionButton({
required BuildContext context,
required bool isDark,
required IconData icon,
required Color iconColor,
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: iconColor.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
),
child: Center(child: Icon(icon, color: iconColor, 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: iconColor.withValues(alpha: 0.1),
),
child: const Icon(
Icons.arrow_forward_rounded,
size: 16,
color: Colors.grey,
),
),
],
),
),
),
);
}
Widget _buildContactCard(ThemeData theme, bool isDark) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: _cardDecoration(theme, isDark),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.help_outline_rounded,
size: 22,
color: theme.colorScheme.primary,
),
const SizedBox(width: 10),
Text(
'Need Help?',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
),
),
],
),
const SizedBox(height: 12),
Text(
'If you have any questions about the deletion process or '
'would like assistance, our support team is here to help.',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: isDark ? Colors.white60 : Colors.grey.shade600,
height: 1.5,
),
),
const SizedBox(height: 16),
Row(
children: [
Icon(
Icons.phone_rounded,
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'+263 78 777 0295',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : Colors.black87,
),
),
],
),
const SizedBox(height: 8),
Row(
children: [
Icon(
Icons.email_outlined,
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Text(
'support@velocityafrica.net',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : Colors.black87,
),
),
],
),
],
),
);
}
BoxDecoration _cardDecoration(ThemeData theme, bool isDark) {
return 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),
),
],
);
}
}

View File

@@ -8,10 +8,13 @@ import 'package:provider/provider.dart';
class GatewayModel { class GatewayModel {
bool isLoading = false; bool isLoading = false;
String status = ''; String status = 'PENDING';
String pollStatus = 'PENDING';
String? errorMessage; String? errorMessage;
bool isCancelled = false; bool isCancelled = false;
int count = 0; int count = 0;
int pollAttempt = 0;
int pollMaxAttempts = 30;
} }
class GatewayController extends ChangeNotifier { class GatewayController extends ChangeNotifier {
@@ -57,7 +60,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,41 +86,46 @@ 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 attempt = 0; int attempt = 0;
while (!model.isCancelled && attempt < maxAttempts) { model.pollAttempt = 0;
notifyListeners();
while (!model.isCancelled && attempt < model.pollMaxAttempts) {
attempt++; attempt++;
model.pollAttempt = attempt;
// Wait 10 seconds before each poll request notifyListeners();
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
if (result.isSuccess) { if (result.isSuccess) {
String status = model.status;
// If status is SUCCESS, we're done // If status is SUCCESS, we're done
if (status == 'SUCCESS') { if (model.status == 'SUCCESS') {
model.isCancelled = true;
model.pollStatus = model.status;
finishLoading();
notifyListeners();
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 {
// Network error — show failure UI and stop await Future.delayed(const Duration(seconds: 3));
model.isCancelled = true; if (model.isCancelled) break;
model.status = 'FAILED'; model.status = 'PENDING';
model.errorMessage = result.error;
return result;
} }
notifyListeners();
} }
// Timed out after max attempts // Timed out after max attempts
model.isCancelled = true; model.isCancelled = true;
model.status = 'FAILED'; model.status = 'FAILED';
model.pollStatus = 'FAILED';
model.errorMessage = 'We failed to check your transaction status'; model.errorMessage = 'We failed to check your transaction status';
finishLoading(); finishLoading();
notifyListeners(); notifyListeners();

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,38 @@ 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? confirmError;
final String? integrationError;
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,
this.confirmError,
this.integrationError,
}); });
factory GroupBatchItem.fromJson(Map<String, dynamic> json) => factory GroupBatchItem.fromJson(Map<String, dynamic> json) =>
@@ -78,24 +96,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 +124,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 +143,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,19 @@ 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?,
confirmError: json['confirmError'] as String?,
integrationError: json['integrationError'] as String?,
); );
Map<String, dynamic> _$GroupBatchItemToJson(GroupBatchItem instance) => Map<String, dynamic> _$GroupBatchItemToJson(GroupBatchItem instance) =>
@@ -62,56 +72,66 @@ 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,
'confirmError': instance.confirmError,
'integrationError': instance.integrationError,
'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 +141,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.',
);
} }
} }

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