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
"version": "0.2.0",
"configurations": [
{
{
"name": "qpay",
"request": "launch",
"type": "dart",
"deviceId": "chrome",
"args": [
"--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 flutter clean
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

View File

@@ -40,7 +40,8 @@ flutter run --dart-define=APP_ENV=live
```bash
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

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"?>
<!-- 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">
<item android:drawable="?android:colorBackground" />
<!-- 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>
<item android:drawable="@color/splash_background" />
</layer-list>

View File

@@ -1,17 +1,5 @@
<?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">
<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>

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">
<background android:drawable="@color/ic_launcher_background"/>
<background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</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
android.useAndroidX=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
zipStoreBase=GRADLE_USER_HOME
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 {
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
id("com.google.gms.google-services") version("4.3.15") apply false
// 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://173.212.247.232: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
SIMULATE_PAYMENT_SUCCESS=false
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
SIMULATE_PAYMENT_SUCCESS=false
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"

View File

@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.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:flutter_dotenv/flutter_dotenv.dart';
import 'package:logger/logger.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:qpay/config/app_config.dart';
var logger = Logger(printer: PrettyPrinter());
class Http {
final Dio dio = Dio();
final String baseUrl = dotenv.env['BASE_URL'] ?? '';
final String baseUrl = AppConfig.baseUrl;
Http() {
dio.options.baseUrl = baseUrl;
@@ -45,8 +45,34 @@ class Http {
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 {
Map<String, String> headers = {'Content-Type': 'application/json'};
Map<String, String> headers = await getHeaders();
Response response;
response = await dio.post(
@@ -58,6 +84,19 @@ class Http {
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 {
Map<String, String> headers = await getHeaders();
@@ -82,4 +121,28 @@ class Http {
logger.i(response.data.toString());
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:js_interop';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:qpay/config/app_config.dart';
import 'package:web/web.dart' as web;
const String _defaultGatewayScriptUrl =
'https://test-gateway.mastercard.com/static/checkout/checkout.min.js';
@JS('Checkout')
external JSAny? _checkout;
@@ -23,8 +20,7 @@ Future<void> ensureCheckoutScriptLoaded() async {
return;
}
final scriptUrl =
dotenv.env['GATEWAY_SCRIPT_URL'] ?? _defaultGatewayScriptUrl;
final scriptUrl = AppConfig.gatewayScriptUrl;
final scriptElement = web.HTMLScriptElement()
..id = 'mastercard-checkout-script'

View File

@@ -1,36 +1,26 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
import 'package:provider/provider.dart';
import 'package:qpay/auth_state.dart';
import 'package:qpay/routes.dart';
import 'package:qpay/url_strategy.dart';
import 'package:qpay/screens/transaction_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:flutter_dotenv/flutter_dotenv.dart';
import 'package:firebase_core/firebase_core.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 {
WidgetsFlutterBinding.ensureInitialized();
usePathUrlStrategy();
if (kIsWeb) {
usePathUrlStrategy();
}
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
await dotenv.load(fileName: resolveEnvFile(appEnv));
// Read the persisted auth token before the first frame so the router
// knows whether the user is already signed in on cold start. Without
// this we would briefly treat logged-in users as guests and bounce
@@ -46,6 +36,14 @@ void main() async {
ChangeNotifierProvider<OnboardingController>(
create: (_) => OnboardingController(),
),
ChangeNotifierProvider<SplashProvider>(create: (_) => SplashProvider()),
ChangeNotifierProvider<WorkspaceProvider>(
create: (_) => WorkspaceProvider(),
),
ChangeNotifierProvider<UptimeProvider>(create: (_) => UptimeProvider()),
ChangeNotifierProvider<AccountProvider>(
create: (_) => AccountProvider(),
),
],
child: const MyApp(),
),

View File

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

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> {
bool _isLoggedIn = false;
late String initials;
@override
@@ -29,7 +28,6 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
if (prefs.getString("token") != null) {
setState(() {
_isLoggedIn = true;
initials = prefs.getString("initials")!;
});
}
@@ -51,13 +49,17 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
Expanded(
child: NavigationRail(
extended: true,
leading: Padding(
padding: const EdgeInsets.symmetric(
vertical: 12.0,
),
child: Image(
image: AssetImage('assets/velocity.png'),
width: 150,
leading: Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 12.0,
horizontal: 8,
),
child: Image(
image: AssetImage('assets/velocity.png'),
width: 150,
),
),
),
labelType: NavigationRailLabelType.none,
@@ -153,6 +155,11 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
"selectedIcon": Icon(Icons.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:
context.go('/history');
break;
case 3:
context.go('/more');
break;
}
}
@@ -174,6 +184,10 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
final String location = GoRouterState.of(context).uri.path;
if (location.startsWith('/groups')) return 1;
if (location.startsWith('/history')) return 2;
if (location.startsWith('/more') ||
location.startsWith('/users') ||
location.startsWith('/uptime'))
return 3;
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_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/batch_detail_screen.dart';
import 'package:qpay/screens/groups/batch_item_screen.dart';
import 'package:qpay/screens/groups/group_create_screen.dart';
import 'package:qpay/screens/groups/group_detail_screen.dart';
import 'package:qpay/screens/groups/groups_screen.dart';
import 'package:qpay/screens/groups/screens/batch_create_screen.dart';
import 'package:qpay/screens/groups/screens/batch_detail_screen.dart';
import 'package:qpay/screens/groups/screens/batch_item_screen.dart';
import 'package:qpay/screens/groups/screens/create_group_from_file_screen.dart';
import 'package:qpay/screens/groups/screens/group_create_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/recipient_group_model.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/receipt/receipt_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
/// 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
/// whose path starts with one of these strings will be redirected
/// 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) {
for (final prefix in _authProtectedRoutePrefixes) {
@@ -134,9 +141,10 @@ GoRouter buildRouter() {
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') {
return splashState.intendedRoute ?? '/';
return '/workspace';
}
}
@@ -149,15 +157,24 @@ GoRouter buildRouter() {
path: '/splash',
builder: (context, state) => const SplashScreen(),
),
// Publicly accessible account deletion screen (Play Store requirement)
GoRoute(
path: '/delete-account',
builder: (context, state) => const DeleteAccountScreen(),
),
ShellRoute(
builder: (context, state, child) {
final location = GoRouterState.of(context).uri.toString();
if (location.startsWith('/onboarding/')) {
if (location.startsWith('/onboarding/') || location == '/workspace') {
return child;
}
return ScaffoldWithNavBar(child: child);
},
routes: [
GoRoute(
path: '/workspace',
builder: (context, state) => const WorkspaceScreen(),
),
GoRoute(path: '/', builder: (context, state) => const HomeScreen()),
GoRoute(
path: '/home',
@@ -218,6 +235,22 @@ GoRouter buildRouter() {
path: '/profile',
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(
path: '/groups',
builder: (context, state) => const GroupsScreen(),
@@ -226,6 +259,10 @@ GoRouter buildRouter() {
path: '/groups/create',
builder: (context, state) => const GroupCreateScreen(),
),
GoRoute(
path: '/groups/create-from-file',
builder: (context, state) => const CreateGroupFromFileScreen(),
),
GoRoute(
path: '/groups/:groupId',
builder: (context, state) {
@@ -241,11 +278,10 @@ GoRouter buildRouter() {
},
),
GoRoute(
path: '/groups/:groupId/batches/:batchId',
path: '/groups/batches/:batchId',
builder: (context, state) {
final batchId = state.pathParameters['batchId']!;
final groupId = state.pathParameters['groupId']!;
return BatchDetailScreen(batchId: batchId, groupId: groupId);
return BatchDetailScreen(batchId: batchId);
},
),
GoRoute(

View File

@@ -17,24 +17,59 @@ class AccountProvider extends ChangeNotifier {
String? _accountError;
String? _statementError;
/// Cached balances keyed by currency code (e.g. 'USD', 'ZWG').
final Map<String, AccountModel?> _balances = {};
AccountModel? get account => _account;
StatementModel? get statement => _statement;
bool get isLoadingAccount => _isLoadingAccount;
bool get isLoadingStatement => _isLoadingStatement;
String? get accountError => _accountError;
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;
_accountError = null;
notifyListeners();
try {
Map<String, dynamic> response = await http.get(
'/accounts/phone/$phoneNumber',
'/accounts/$workspaceId/balance/$currency',
);
_account = AccountModel.fromJson(response);
_balances[currency.toUpperCase()] = _account;
_isLoadingAccount = false;
notifyListeners();
return ApiResponse.success(_account!);
@@ -45,6 +80,7 @@ class AccountProvider extends ChangeNotifier {
notifyListeners();
return ApiResponse.failure(_accountError!);
} catch (e, s) {
logger.e(e);
logger.e(s);
_isLoadingAccount = false;
_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({
required String phoneNumber,
required String workspaceId,
required String currency,
required String startDate,
required String endDate,
}) async {
@@ -64,7 +112,7 @@ class AccountProvider extends ChangeNotifier {
try {
Map<String, dynamic> response = await http.get(
'/accounts/phone/$phoneNumber/statement'
'/accounts/$workspaceId/statement/$currency'
'?startDate=$startDate&endDate=$endDate',
);

View File

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

View File

@@ -1,10 +1,15 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.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/widgets/app_snack_bar.dart';
import 'package:skeletonizer/skeletonizer.dart';
import 'package:url_launcher/url_launcher.dart';
import '../transactions/transaction_model.dart';
class ConfirmScreen extends StatefulWidget {
const ConfirmScreen({super.key});
@@ -120,7 +125,23 @@ class _ConfirmScreenState extends State<ConfirmScreen>
.authType ==
"WEB") {
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 {
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
Widget build(BuildContext context) {
return Scaffold(
@@ -370,6 +521,15 @@ class _ConfirmScreenState extends State<ConfirmScreen>
children: [
_buildDetailRow(
icon: Icons.monetization_on_outlined,
label: "Currency",
value: controller
.transactionController
.model
.confirmationData["debitCurrency"]
.toString(),
),
_buildDetailRow(
icon: Icons.money,
label: "Amount",
value: controller
.transactionController
@@ -591,42 +751,112 @@ class _ConfirmScreenState extends State<ConfirmScreen>
onTap: () async {
_handlePayment();
},
child: ListTile(
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Image(
image: AssetImage(
"assets/${controller.transactionController.model.selectedPaymentProcessor.image}",
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(
context,
).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),
),
),
),
),
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) {
final accountProvider = context.watch<AccountProvider>();
final currency = controller
.transactionController
.model
.confirmationData["debitCurrency"]
?.toString();
final cityWallet = currency != null
? accountProvider.balanceForCurrency(currency)
: null;
final hasCityWallet = cityWallet != null;
if (!hasCityWallet) return const SizedBox.shrink();
if (controller
.transactionController
.model
.selectedPaymentProcessor
.label !=
'WALLET') {
return const SizedBox.shrink();
}
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 {
bool isLoading = false;
String status = '';
String status = 'PENDING';
String pollStatus = 'PENDING';
String? errorMessage;
bool isCancelled = false;
int count = 0;
int pollAttempt = 0;
int pollMaxAttempts = 30;
}
class GatewayController extends ChangeNotifier {
@@ -57,7 +60,7 @@ class GatewayController extends ChangeNotifier {
dynamic response = workflowResponse['body'];
model.status = response['status'];
model.status = response['pollingStatus'];
transactionController.model.paymentStatus = response['paymentStatus'];
finishLoading();
@@ -83,41 +86,46 @@ class GatewayController extends ChangeNotifier {
}
Future<ApiResponse<Map<String, dynamic>>> pollTransaction(String uid) async {
// poll up to 30 times (~5 minutes at 10-second intervals)
int maxAttempts = 15;
// poll up to 15 times (~45 seconds at 3-second intervals)
int attempt = 0;
while (!model.isCancelled && attempt < maxAttempts) {
model.pollAttempt = 0;
notifyListeners();
while (!model.isCancelled && attempt < model.pollMaxAttempts) {
attempt++;
// Wait 10 seconds before each poll request
await Future.delayed(const Duration(seconds: 10));
// Check again after delay in case it was cancelled during the delay
if (model.isCancelled) break;
model.pollAttempt = attempt;
notifyListeners();
ApiResponse<Map<String, dynamic>> result = await poll(uid);
// Only stop on success or failure
if (result.isSuccess) {
String status = model.status;
// 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;
}
await Future.delayed(const Duration(seconds: 3));
if (model.isCancelled) break;
// Otherwise (e.g. PENDING), continue polling
} else {
// Network error — show failure UI and stop
model.isCancelled = true;
model.status = 'FAILED';
model.errorMessage = result.error;
return result;
await Future.delayed(const Duration(seconds: 3));
if (model.isCancelled) break;
model.status = 'PENDING';
}
notifyListeners();
}
// Timed out after max attempts
model.isCancelled = true;
model.status = 'FAILED';
model.pollStatus = 'FAILED';
model.errorMessage = 'We failed to check your transaction status';
finishLoading();
notifyListeners();

View File

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

View File

@@ -1,9 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.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/screens/gateway/gateway_controller.dart';
import 'package:web/web.dart' as web;
import 'package:qpay/browser.dart';
class GatewayWebScreen extends StatefulWidget {
const GatewayWebScreen({super.key});
@@ -34,9 +34,7 @@ class _GatewayWebScreenState extends State<GatewayWebScreen> {
redirecting = true;
});
bool simulate = bool.parse(
dotenv.env['SIMULATE_PAYMENT_SUCCESS'] ?? 'false',
);
bool simulate = AppConfig.simulatePaymentSuccess;
if (simulate) {
// In simulation mode, just go to polling
WidgetsBinding.instance.addPostFrameCallback((_) {
@@ -50,7 +48,7 @@ class _GatewayWebScreenState extends State<GatewayWebScreen> {
final transactionId =
controller.transactionController.model.confirmationData['id'];
if (transactionId != null) {
web.window.localStorage.setItem(
Browser.localStorage.setItem(
'pendingTransactionId',
transactionId.toString(),
);
@@ -60,7 +58,7 @@ class _GatewayWebScreenState extends State<GatewayWebScreen> {
// After payment, the gateway will redirect back to this app's host.
// index.html then reads the saved transaction ID and navigates to
// /poll/:id so the app can immediately start polling for the result.
web.window.location.href = url;
Browser.location.href = url;
}
@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/transactions/transaction_model.dart';
import '../models/batch_item_update_model.dart';
class BatchScreenModel {
List<GroupBatch> batches = [];
List<GroupBatchItem> batchItems = [];
@@ -22,11 +24,26 @@ class BatchScreenModel {
int currentPage = 0;
int totalPages = 0;
int totalElements = 0;
int pageSize = 10;
String searchQuery = '';
String? statusFilter;
String? currencyFilter;
bool selectMode = false;
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 {
@@ -62,10 +79,10 @@ class BatchController extends ChangeNotifier {
return query.substring(0, query.length - 1);
}
Future<ApiResponse<List<BillProvider>>> loadProviders() async {
Future<ApiResponse<List<BillProvider>>> loadProviders(String currency) async {
try {
final raw = await http.get(
'/public/providers?currency=USD&sort=priority,asc',
'/public/providers?currency=$currency&sort=priority,asc',
);
final response = _unwrap(raw);
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 {
final raw = await http.get('/public/payment-processors');
final raw = await http.get(
'/public/payment-processors?currency=$currency',
);
final response = _unwrap(raw);
final List<dynamic> content = response is List
? 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(
String groupId, {
int page = 0,
int size = 20,
int size = 10,
String? search,
String? status,
String? currency,
@@ -146,7 +201,7 @@ class BatchController extends ChangeNotifier {
}
final raw = await http.get(
'/public/group-batches?${buildQueryParameters(params)}',
'/group-batches?${buildQueryParameters(params)}',
);
final response = _unwrap(raw);
final pageableModel = PageableModel.fromJson(
@@ -195,6 +250,7 @@ class BatchController extends ChangeNotifier {
await listBatches(
groupId,
page: 0,
size: model.pageSize,
search: search,
status: status,
currency: currency,
@@ -208,6 +264,43 @@ class BatchController extends ChangeNotifier {
await listBatches(
groupId,
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,
status: model.statusFilter,
currency: model.currencyFilter,
@@ -215,50 +308,170 @@ class BatchController extends ChangeNotifier {
}
Future<ApiResponse<List<GroupBatchItem>>> listBatchItems(
String batchId,
) async {
model.isLoading = true;
String batchId, {
int page = 0,
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();
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(
'/public/group-batches/items?groupBatchId=$batchId&sort=createdAt,desc',
'/group-batches/items?${buildQueryParameters(params)}',
);
final response = _unwrap(raw);
final List<dynamic> content;
if (response is Map && response.containsKey('content')) {
content = PageableModel.fromJson(
response as Map<String, dynamic>,
).content;
} else if (response is List) {
content = response;
} else {
content = [];
}
model.batchItems = content
final pageableModel = PageableModel.fromJson(
response as Map<String, dynamic>,
);
final newItems = pageableModel.content
.map((e) => GroupBatchItem.fromJson(e as Map<String, dynamic>))
.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.batchItemsIsLoadingMore = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(model.batchItems);
} catch (e) {
logger.e(e);
model.errorMessage = e.toString();
model.batchItems = [];
if (page == 0) {
model.batchItems = [];
}
model.isLoading = false;
model.batchItemsIsLoadingMore = false;
if (!_disposed) notifyListeners();
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(
String batchId,
String userId,
String workspaceId,
) async {
model.isActing = true;
if (!_disposed) notifyListeners();
try {
final raw = await http.get(
'/public/group-batches/$batchId?userId=$userId',
'/group-batches/$batchId?workspaceId=$workspaceId',
);
final response = _unwrap(raw);
final batch = GroupBatch.fromJson(response as Map<String, dynamic>);
@@ -278,7 +491,7 @@ class BatchController extends ChangeNotifier {
model.isActing = true;
if (!_disposed) notifyListeners();
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 batch = GroupBatch.fromJson(response as Map<String, dynamic>);
model.currentBatch = batch;
@@ -301,7 +514,7 @@ class BatchController extends ChangeNotifier {
if (!_disposed) notifyListeners();
try {
final raw = await http.put(
'/public/group-batches/${request.id}',
'/group-batches/${request.id}',
request.toJson(),
);
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(
String batchId,
String userId,
String workspaceId,
String authType,
) async {
model.isActing = true;
if (!_disposed) notifyListeners();
try {
final key =
'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(
'/public/group-batches/$batchId/confirm',
'/group-batches/$batchId/confirm',
body.toJson(),
);
final response = _unwrap(raw);
@@ -354,16 +601,19 @@ class BatchController extends ChangeNotifier {
Future<ApiResponse<TransactionModel>> requestBatch(
String batchId,
String userId,
String workspaceId,
) async {
model.isActing = true;
if (!_disposed) notifyListeners();
try {
final key =
'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(
'/public/group-batches/$batchId/request',
'/group-batches/$batchId/request',
body.toJson(),
);
final response = _unwrap(raw);
@@ -382,13 +632,13 @@ class BatchController extends ChangeNotifier {
Future<ApiResponse<Map<String, dynamic>>> downloadBatchReport(
String batchId,
String userId,
String workspaceId,
) async {
model.isActing = true;
if (!_disposed) notifyListeners();
try {
final raw = await http.get(
'/public/group-batches/$batchId/report?userId=$userId',
'/group-batches/$batchId/report?workspaceId=$workspaceId',
);
final response = _unwrap(raw);
model.isActing = false;
@@ -405,16 +655,19 @@ class BatchController extends ChangeNotifier {
Future<ApiResponse<TransactionModel>> pollBatch(
String batchId,
String userId,
String workspaceId,
) async {
model.isActing = true;
if (!_disposed) notifyListeners();
try {
final key =
'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(
'/public/group-batches/$batchId/poll',
'/group-batches/$batchId/poll',
body.toJson(),
);
final response = _unwrap(raw);
@@ -423,7 +676,7 @@ class BatchController extends ChangeNotifier {
);
model.isActing = false;
if (!_disposed) notifyListeners();
if (response['status'] != 'FAILED') {
if (batchTransaction.pollingStatus == 'SUCCESS') {
return ApiResponse.success(batchTransaction);
} else {
return ApiResponse.failure(
@@ -441,10 +694,10 @@ class BatchController extends ChangeNotifier {
Future<void> deleteBatch({
required String batchId,
required String userId,
required String workspaceId,
}) async {
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.selectedBatchIds.remove(batchId);
model.currentBatch = null;
@@ -458,7 +711,7 @@ class BatchController extends ChangeNotifier {
Future<void> deleteSelectedBatches({
required String groupId,
required String userId,
required String workspaceId,
}) async {
final idsToDelete = List<String>.from(model.selectedBatchIds);
model.selectMode = false;
@@ -466,7 +719,7 @@ class BatchController extends ChangeNotifier {
if (!_disposed) notifyListeners();
for (final id in idsToDelete) {
await deleteBatch(batchId: id, userId: userId);
await deleteBatch(batchId: id, workspaceId: workspaceId);
}
// Refresh after deletion

View File

@@ -2,11 +2,13 @@ import 'package:flutter/material.dart';
import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:qpay/models/pageable_model.dart';
import 'package:qpay/screens/groups/models/create_group_from_file_response.dart';
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
class GroupScreenModel {
List<RecipientGroup> groups = [];
List<RecipientGroupMember> members = [];
PageableModel? membersPage;
bool isLoading = false;
bool isLoadingMore = false;
String? errorMessage;
@@ -52,7 +54,7 @@ class GroupController extends ChangeNotifier {
}
Future<ApiResponse<PageableModel>> listGroups({
required String userId,
required String workspaceId,
int page = 0,
int size = 20,
String? name,
@@ -68,7 +70,7 @@ class GroupController extends ChangeNotifier {
try {
final params = <String, String>{
'userId': userId,
'workspaceId': workspaceId,
'page': page.toString(),
'size': size.toString(),
};
@@ -77,11 +79,12 @@ class GroupController extends ChangeNotifier {
}
final raw = await http.get(
'/public/recipient-groups?${buildQueryParameters(params)}',
'/recipient-groups?${buildQueryParameters(params)}',
);
final response = _unwrap(raw);
final pageableModel =
PageableModel.fromJson(response as Map<String, dynamic>);
final pageableModel = PageableModel.fromJson(
response as Map<String, dynamic>,
);
final newGroups = pageableModel.content
.map((e) => RecipientGroup.fromJson(e as Map<String, dynamic>))
@@ -113,20 +116,17 @@ class GroupController extends ChangeNotifier {
}
}
Future<void> searchGroups({
required String userId,
String? name,
}) async {
Future<void> searchGroups({required String workspaceId, String? name}) async {
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) {
return;
}
await listGroups(
userId: userId,
workspaceId: workspaceId,
page: model.currentPage + 1,
name: model.searchQuery.isNotEmpty ? model.searchQuery : null,
);
@@ -139,9 +139,10 @@ class GroupController extends ChangeNotifier {
if (!_disposed) notifyListeners();
try {
final raw = await http.get(
'/public/recipient-groups/members?recipientGroupId=$groupId',
'/recipient-groups/members?recipientGroupId=$groupId',
);
final response = _unwrap(raw);
model.membersPage = PageableModel.fromJson(response);
final List<dynamic> content;
if (response is List) {
content = response;
@@ -173,7 +174,7 @@ class GroupController extends ChangeNotifier {
model.isLoading = true;
if (!_disposed) notifyListeners();
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 group = RecipientGroup.fromJson(response as Map<String, dynamic>);
model.groups = [group, ...model.groups];
@@ -191,11 +192,11 @@ class GroupController extends ChangeNotifier {
Future<void> deleteGroup({
required String groupId,
required String userId,
required String workspaceId,
}) async {
try {
await http.delete(
'/public/recipient-groups/delete/$groupId?userId=$userId',
'/recipient-groups/delete/$groupId?workspaceId=$workspaceId',
);
model.groups.removeWhere((g) => g.id == groupId);
model.selectedGroupIds.remove(groupId);
@@ -227,11 +228,11 @@ class GroupController extends ChangeNotifier {
Future<void> removeMember({
required String groupId,
required String recipientId,
required String userId,
required String workspaceId,
}) async {
try {
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);
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);
model.selectMode = false;
model.selectedGroupIds.clear();
if (!_disposed) notifyListeners();
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? recipientGroupId;
final String? userId;
final String? workspaceId;
final String? currency;
final String? description;
final double? value;
@@ -24,6 +25,7 @@ class GroupBatch {
this.id,
this.recipientGroupId,
this.userId,
this.workspaceId,
this.currency,
this.description,
this.value,
@@ -50,22 +52,38 @@ class GroupBatchItem {
final String? groupBatchId;
final String? recipientName;
final String? recipientPhone;
final String? recipientAccount;
final double? amount;
final String? status;
final String? transactionId;
final String? errorMessage;
final String? confirmError;
final String? integrationError;
final String? createdAt;
final String? billName;
final String? billProductName;
final String? providerLabel;
final String? providerImage;
final String? creditAddress;
const GroupBatchItem({
this.id,
this.groupBatchId,
this.recipientName,
this.recipientPhone,
this.recipientAccount,
this.amount,
this.status,
this.transactionId,
this.errorMessage,
this.createdAt,
this.billName,
this.billProductName,
this.providerLabel,
this.providerImage,
this.creditAddress,
this.confirmError,
this.integrationError,
});
factory GroupBatchItem.fromJson(Map<String, dynamic> json) =>
@@ -78,24 +96,24 @@ class GroupBatchItem {
class CreateBatchRequest {
final String recipientGroupId;
final String userId;
final String workspaceId;
final String currency;
final String description;
final double amount;
final String? paymentProcessorLabel;
final String? paymentProcessorName;
final String? paymentProcessorImage;
final Map<String, dynamic> transactionTemplate;
const CreateBatchRequest({
required this.recipientGroupId,
required this.userId,
required this.workspaceId,
required this.currency,
required this.description,
required this.amount,
this.paymentProcessorLabel,
this.paymentProcessorName,
this.paymentProcessorImage,
required this.transactionTemplate,
});
factory CreateBatchRequest.fromJson(Map<String, dynamic> json) =>
@@ -106,12 +124,14 @@ class CreateBatchRequest {
@JsonSerializable()
class BatchActionRequest {
final String userId;
final String workspaceId;
final String idempotencyKey;
final String? authType;
const BatchActionRequest({
required this.userId,
required this.workspaceId,
required this.idempotencyKey,
this.authType,
});
factory BatchActionRequest.fromJson(Map<String, dynamic> json) =>
@@ -123,14 +143,14 @@ class BatchActionRequest {
@JsonSerializable(explicitToJson: true)
class GroupBatchUpdateRequest {
String? id;
String? userId;
String? workspaceId;
String? paymentProcessorLabel;
String? paymentProcessorName;
String? paymentProcessorImage;
GroupBatchUpdateRequest({
this.id,
this.userId,
this.workspaceId,
this.paymentProcessorLabel,
this.paymentProcessorName,
this.paymentProcessorImage,

View File

@@ -10,6 +10,7 @@ GroupBatch _$GroupBatchFromJson(Map<String, dynamic> json) => GroupBatch(
id: json['id'] as String?,
recipientGroupId: json['recipientGroupId'] as String?,
userId: json['userId'] as String?,
workspaceId: json['workspaceId'] as String?,
currency: json['currency'] as String?,
description: json['description'] as String?,
value: (json['value'] as num?)?.toDouble(),
@@ -29,6 +30,7 @@ Map<String, dynamic> _$GroupBatchToJson(GroupBatch instance) =>
'id': instance.id,
'recipientGroupId': instance.recipientGroupId,
'userId': instance.userId,
'workspaceId': instance.workspaceId,
'currency': instance.currency,
'description': instance.description,
'value': instance.value,
@@ -49,11 +51,19 @@ GroupBatchItem _$GroupBatchItemFromJson(Map<String, dynamic> json) =>
groupBatchId: json['groupBatchId'] as String?,
recipientName: json['recipientName'] as String?,
recipientPhone: json['recipientPhone'] as String?,
recipientAccount: json['recipientAccount'] as String?,
amount: (json['amount'] as num?)?.toDouble(),
status: json['status'] as String?,
transactionId: json['transactionId'] as String?,
errorMessage: json['errorMessage'] as String?,
createdAt: json['createdAt'] as String?,
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) =>
@@ -62,56 +72,66 @@ Map<String, dynamic> _$GroupBatchItemToJson(GroupBatchItem instance) =>
'groupBatchId': instance.groupBatchId,
'recipientName': instance.recipientName,
'recipientPhone': instance.recipientPhone,
'recipientAccount': instance.recipientAccount,
'amount': instance.amount,
'status': instance.status,
'transactionId': instance.transactionId,
'errorMessage': instance.errorMessage,
'confirmError': instance.confirmError,
'integrationError': instance.integrationError,
'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(
recipientGroupId: json['recipientGroupId'] as String,
userId: json['userId'] as String,
workspaceId: json['workspaceId'] as String,
currency: json['currency'] as String,
description: json['description'] as String,
amount: (json['amount'] as num).toDouble(),
paymentProcessorLabel: json['paymentProcessorLabel'] as String?,
paymentProcessorName: json['paymentProcessorName'] as String?,
paymentProcessorImage: json['paymentProcessorImage'] as String?,
transactionTemplate: json['transactionTemplate'] as Map<String, dynamic>,
);
Map<String, dynamic> _$CreateBatchRequestToJson(CreateBatchRequest instance) =>
<String, dynamic>{
'recipientGroupId': instance.recipientGroupId,
'userId': instance.userId,
'workspaceId': instance.workspaceId,
'currency': instance.currency,
'description': instance.description,
'amount': instance.amount,
'paymentProcessorLabel': instance.paymentProcessorLabel,
'paymentProcessorName': instance.paymentProcessorName,
'paymentProcessorImage': instance.paymentProcessorImage,
'transactionTemplate': instance.transactionTemplate,
};
BatchActionRequest _$BatchActionRequestFromJson(Map<String, dynamic> json) =>
BatchActionRequest(
userId: json['userId'] as String,
workspaceId: json['workspaceId'] as String,
idempotencyKey: json['idempotencyKey'] as String,
authType: json['authType'] as String?,
);
Map<String, dynamic> _$BatchActionRequestToJson(BatchActionRequest instance) =>
<String, dynamic>{
'userId': instance.userId,
'workspaceId': instance.workspaceId,
'idempotencyKey': instance.idempotencyKey,
'authType': instance.authType,
};
GroupBatchUpdateRequest _$GroupBatchUpdateRequestFromJson(
Map<String, dynamic> json,
) => GroupBatchUpdateRequest(
id: json['id'] as String?,
userId: json['userId'] as String?,
workspaceId: json['workspaceId'] as String?,
paymentProcessorLabel: json['paymentProcessorLabel'] as String?,
paymentProcessorName: json['paymentProcessorName'] as String?,
paymentProcessorImage: json['paymentProcessorImage'] as String?,
@@ -121,7 +141,7 @@ Map<String, dynamic> _$GroupBatchUpdateRequestToJson(
GroupBatchUpdateRequest instance,
) => <String, dynamic>{
'id': instance.id,
'userId': instance.userId,
'workspaceId': instance.workspaceId,
'paymentProcessorLabel': instance.paymentProcessorLabel,
'paymentProcessorName': instance.paymentProcessorName,
'paymentProcessorImage': instance.paymentProcessorImage,

View File

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

View File

@@ -12,6 +12,7 @@ RecipientGroup _$RecipientGroupFromJson(Map<String, dynamic> json) =>
name: json['name'] as String,
description: json['description'] as String?,
userId: json['userId'] as String?,
workspaceId: json['workspaceId'] as String?,
createdAt: json['createdAt'] as String?,
);
@@ -21,6 +22,7 @@ Map<String, dynamic> _$RecipientGroupToJson(RecipientGroup instance) =>
'name': instance.name,
'description': instance.description,
'userId': instance.userId,
'workspaceId': instance.workspaceId,
'createdAt': instance.createdAt,
};
@@ -53,6 +55,7 @@ CreateGroupRequest _$CreateGroupRequestFromJson(Map<String, dynamic> json) =>
name: json['name'] as String,
description: json['description'] as String?,
userId: json['userId'] as String,
workspaceId: json['workspaceId'] as String,
recipients: (json['recipients'] as List<dynamic>)
.map((e) => Recipient.fromJson(e as Map<String, dynamic>))
.toList(),
@@ -63,5 +66,6 @@ Map<String, dynamic> _$CreateGroupRequestToJson(CreateGroupRequest instance) =>
'name': instance.name,
'description': instance.description,
'userId': instance.userId,
'workspaceId': instance.workspaceId,
'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:shared_preferences/shared_preferences.dart';
import '../recipient/recipients_controller.dart';
import '../../recipient/recipients_controller.dart';
class GroupCreateScreen extends StatefulWidget {
const GroupCreateScreen({super.key});
@@ -49,12 +49,16 @@ class _GroupCreateScreenState extends State<GroupCreateScreen> {
if (parts.length >= 2) {
final name = parts[0].trim();
final account = parts[1].trim();
String phone = '';
if (parts.length > 2) {
phone = parts.sublist(2).join(',').trim();
}
if (name.isNotEmpty && account.isNotEmpty) {
members.add(
Recipient(
name: name,
account: account,
phoneNumber: account,
phoneNumber: phone,
latestProviderLabel: '',
),
);
@@ -78,6 +82,7 @@ class _GroupCreateScreenState extends State<GroupCreateScreen> {
final prefs = await SharedPreferences.getInstance();
final userId = prefs.getString('userId') ?? '';
final workspaceId = prefs.getString('workspaceId') ?? '';
final req = CreateGroupRequest(
name: _nameController.text.trim(),
@@ -85,6 +90,7 @@ class _GroupCreateScreenState extends State<GroupCreateScreen> {
? null
: _descriptionController.text.trim(),
userId: userId,
workspaceId: workspaceId,
recipients: recipients,
);
@@ -95,10 +101,7 @@ class _GroupCreateScreenState extends State<GroupCreateScreen> {
AppSnackBar.showSuccess(context, 'Group created successfully.');
context.pop(true); // Return true to indicate a new group was created
} else {
AppSnackBar.showError(
context,
result.error ?? 'Failed to create group.',
);
AppSnackBar.showError(context, result.error ?? 'Failed to create group.');
}
}

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