Compare commits

...

53 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
Prince
2bcee59fd7 added wallet functionality 2026-06-11 23:48:26 +02:00
Prince
ad79bf2af8 adding CityWallet functionality 2026-06-11 16:33:22 +02:00
b10d233428 fixing landing page 2026-06-08 10:17:22 +02:00
0a267bdbb8 fixing landing page 2026-06-08 10:13:27 +02:00
fc616d6316 completed group batch feature 2026-06-08 09:17:36 +02:00
2dd790fe6e progress on batches 2026-06-05 20:25:20 +02:00
eab2368331 usability fixes 2026-06-01 21:04:06 +02:00
1d8ab2088c usability fixes 2026-06-01 20:49:53 +02:00
7b5b962fe6 minor fixes 2026-06-01 14:54:10 +02:00
59c96d889a minor login fixes 2026-06-01 14:50:02 +02:00
187 changed files with 24686 additions and 3091 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
@@ -42,6 +47,10 @@ FROM nginx:1.25.2-alpine
# copy the info of the builded web app to nginx
COPY --from=build-env /app/build/web /usr/share/nginx/html
# Remove default nginx configuration and copy custom one
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/nginx.conf
# Expose and run nginx
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,7 @@
- [ ] Analyze history_controller and add support for filter (status, type, date) + bulk select
- [ ] Update history_screen to mirror group detail list layout (search + filter + select + refresh + new buttons)
- [ ] Add filter bottom sheet for transactions (status/type)
- [ ] Add bulk action bar (Repeat selected, Delete selected) when select mode is on
- [ ] Add scroll-to-load-more pagination like group detail
- [ ] Tone down animation (short, subtle fade/slide)
- [ ] Verify compile with `flutter analyze`

4225
QPay.postman_collection.json Normal file

File diff suppressed because it is too large Load Diff

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**

View File

@@ -1,3 +1,6 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
// START: FlutterFire Configuration
@@ -8,6 +11,14 @@ plugins {
id("dev.flutter.flutter-gradle-plugin")
}
val keystoreProperties = Properties()
val keystorePropertiesFile = rootProject.file("key.properties")
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
}
android {
namespace = "zw.co.qantra.qpay"
compileSdk = flutter.compileSdkVersion
@@ -34,11 +45,21 @@ android {
versionName = flutter.versionName
}
signingConfigs {
create("release") {
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String
}
}
buildTypes {
release {
// TODO: Add your own signing config for the release build.
// Signing with the debug keys for now, so `flutter run --release` works.
signingConfig = signingConfigs.getByName("debug")
// signingConfig = signingConfigs.getByName("debug")
signingConfig = signingConfigs.getByName("release")
}
}
}

View File

@@ -1,31 +1,16 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
<application
android:label="Velocity"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:forceDarkAllowed="false">
<application android:label="Velocity Pay" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" android:forceDarkAllowed="false">
<!-- Main Activity -->
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:taskAffinity="" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<meta-data android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme"/>
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
@@ -33,9 +18,7 @@
</activity>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<meta-data android:name="flutterEmbedding" android:value="2"/>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

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

BIN
assets/city.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
assets/complete.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 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

BIN
assets/signup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 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://peakapi.qantra.co.zw/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://peakapi.qantra.co.zw/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).*

83
group-batches.md Normal file
View File

@@ -0,0 +1,83 @@
# Plan: Groups & Batches Feature
## API Surface (from Postman)
### Groups
- `GET /api/public/recipient-groups?userId={userId}` — list groups
- `GET /api/public/recipient-groups/members?recipientGroupId={id}` — list members
- `POST /api/public/recipient-groups` — create group
- Body: `{ name, description, userId, recipients: [{name, phoneNumber, latestProviderLabel, account}] }`
### Batches
- `GET /api/public/group-batches?recipientGroupId={id}&sort=createdAt,desc` — list batches
- `GET /api/public/group-batches/items?groupBatchId={id}&sort=createdAt,desc` — list batch items
- `POST /api/public/group-batches` — create batch (recipientGroupId, userId, currency, amount, paymentProcessorLabel, paymentProcessorName, paymentProcessorImage, transactionTemplate)
- `POST /api/public/group-batches/{batchId}/confirm` — confirm batch
- `POST /api/public/group-batches/{batchId}/request` — request batch (payment)
- `POST /api/public/group-batches/{batchId}/poll` — poll batch status
## Files To Create
### Phase 1: Models
- `lib/models/recipient_group_model.dart` — RecipientGroup, RecipientMember, CreateGroupRequest
- `lib/models/group_batch_model.dart` — GroupBatch, GroupBatchItem, CreateBatchRequest (transactionTemplate typed as TransactionModel from lib/screens/transactions/transaction_model.dart), BatchActionRequest. No separate TransactionTemplate class needed.
### Phase 2: Controllers
- `lib/screens/groups/group_controller.dart` — ChangeNotifier, list/create groups, list members
- `lib/screens/groups/batch_controller.dart` — ChangeNotifier, list/create batches, list items, confirm/request/poll
### Phase 3: Screens
- `lib/screens/groups/groups_screen.dart` — list all groups
- `lib/screens/groups/group_create_screen.dart` — create group form (name, description, multiline textarea: one recipient per line as `name,account`; phoneNumber defaults to account value)
- `lib/screens/groups/group_detail_screen.dart` — tabbed view: Batches first (default), Members second; receives RecipientGroup via extra; FAB on Batches tab to create new batch
- `lib/screens/groups/batch_create_screen.dart` — create batch form (provider, processor, amount, currency); on success navigate to BatchDetailScreen passing created batch via extra
- `lib/screens/groups/batch_detail_screen.dart` — batch items list + Confirm/Request/Poll action buttons
- `lib/screens/groups/batch_item_screen.dart` — single batch item detail view
### Phase 4: Integration
- `lib/navbar.dart` — add Groups as 3rd nav item (index 2)
- `lib/main.dart` — add routes + GroupController + BatchController providers
## Routes
- `/groups` → GroupsScreen
- `/groups/create` → GroupCreateScreen
- `/groups/:groupId` → GroupDetailScreen
- `/groups/:groupId/batches/create` → BatchCreateScreen
- `/groups/:groupId/batches/:batchId` → BatchDetailScreen
- `/groups/:groupId/batches/:batchId/items/:itemId` → BatchItemScreen
## Action Return Types
- `createBatch``ApiResponse<GroupBatch>`
- `confirmBatch``ApiResponse<GroupBatch>`
- `requestBatch``ApiResponse<TransactionModel>` (reuses TransactionModel)
- `pollBatch``ApiResponse<GroupBatch>`
- List endpoints → `ApiResponse<PageableModel<T>>`
## BatchDetailScreen Auto-Poll Logic
After **Request** action:
1. `requestBatch()` returns `TransactionModel`
2. Check `transaction.pollingStatus` — if successful (e.g. `SUCCESS`, `COMPLETE`) → no poll needed, refresh batch
3. Otherwise → auto-trigger `pollBatch()` immediately without user input
4. If auto-poll fails (network error / non-terminal status) → show **Poll** button for manual retry
5. Batch status from poll response drives UI state (show/hide action buttons)
## Key Patterns
- Controllers extend ChangeNotifier
- Models use `@JsonSerializable` with generated `.g.dart` files
- All network calls use `ApiResponse<T>` wrapping pattern (try/catch in controller)
- Use `ListenableBuilder` in screens
- Read `userId`/`token` from SharedPreferences
- Pass extra data via GoRouter `extra:` parameter
- `idempotencyKey` for batch actions generated as `"batch-{batchId}-{action}-{timestamp}"`
- Navbar: add Groups at index 2, update `_handleTap` + `_calculateSelectedIndex`
## Member Input Parsing (group_create_screen)
- One recipient per line: `name,account`
- `phoneNumber` defaults to `account` value
- `latestProviderLabel` defaults to `""`
## Decisions
- `TransactionModel` reused as-is for `transactionTemplate` field — no wrapper class
- No pagination on groups/batches lists in this iteration
- No edit/delete for groups or batches in scope
- Poll button only shown as manual fallback after auto-poll failure

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

View File

@@ -1,64 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Velocity</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Velocity</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>Velocity Pay</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Velocity Pay</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
<key>NSAllowsArbitraryLoadsInWebContent</key>
<true/>
</dict>
<key>NSContactsUsageDescription</key>
<string>To fetch recipient phone numbers</string>
<key>UIUserInterfaceStyle</key>
<string>Light</string>
</dict>
</plist>
<key>NSContactsUsageDescription</key>
<string>To fetch recipient phone numbers</string>
<key>UIUserInterfaceStyle</key>
<string>Light</string>
</dict>
</plist>

View File

@@ -1,17 +1,12 @@
import 'package:flutter/material.dart';
import 'package:qpay/widgets/app_snack_bar.dart';
class AbstractController extends ChangeNotifier {
late BuildContext context;
void showErrorSnackBar(String? message) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message.toString()),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.deepOrange,
),
);
AppSnackBar.showError(context, message.toString());
});
}
}
}

87
lib/auth_state.dart Normal file
View File

@@ -0,0 +1,87 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Tracks the user's authentication state so the [GoRouter] can decide
/// whether to allow access to protected routes (such as the groups
/// flow) or redirect to the sign-in screen.
///
/// The router is wired to refresh whenever [notifyListeners] is called,
/// which means any place that mutates the auth state (login, logout,
/// registration completion) just has to call [refresh] to keep the
/// navigation layer in sync.
class AuthState extends ChangeNotifier {
/// SharedPreferences key used to persist the auth token returned by
/// the backend after a successful sign-in.
static const String _tokenKey = 'token';
bool _isLoggedIn = false;
bool _initialized = false;
/// The route the user originally tried to visit before being sent to
/// the sign-in screen. After a successful login, the redirect logic
/// reads and clears this so the user lands where they were heading.
String? _intendedRoute;
bool get isLoggedIn => _isLoggedIn;
String? get intendedRoute => _intendedRoute;
/// Loads the current auth state from disk. Safe to call multiple
/// times; subsequent calls just re-read the token value.
Future<void> initialize() async {
final prefs = await SharedPreferences.getInstance();
_update(prefs.getString(_tokenKey) != null);
_initialized = true;
}
/// Re-reads the auth state from disk and notifies listeners if it
/// has changed. Call this from places that mutate the persisted
/// token (e.g. after the login API call or after the user logs out).
Future<void> refresh() async {
final prefs = await SharedPreferences.getInstance();
_update(prefs.getString(_tokenKey) != null);
}
void _update(bool loggedIn) {
if (_isLoggedIn == loggedIn) return;
_isLoggedIn = loggedIn;
// NOTE: we intentionally do NOT clear _intendedRoute here. The
// router's redirect callback reads it on the next build frame via
// [consumeIntendedRoute], which is responsible for clearing it
// after it has been consumed.
notifyListeners();
}
/// Called by the router's redirect callback when a protected route
/// is hit by an unauthenticated user. Stores the route the user was
/// trying to visit and returns the sign-in path the router should
/// navigate to.
String? requireAuth(String attemptedRoute) {
if (_isLoggedIn) return null;
// Avoid stacking redirects when the user is already on the
// sign-in / sign-up screens.
if (attemptedRoute.startsWith('/onboarding/')) return null;
_intendedRoute = attemptedRoute;
return '/onboarding/landing';
}
/// Returns the previously captured intended route so the router can
/// navigate the user there after a successful sign-in. The value is
/// consumed (cleared) once read to avoid loops.
String? consumeIntendedRoute() {
if (!_isLoggedIn) return null;
final route = _intendedRoute;
_intendedRoute = null;
return route;
}
}
/// Global singleton so widgets and the router can share the same
/// instance without needing to plumb a provider through the app.
final AuthState authState = AuthState();
/// Whether the auth state has finished its first read from disk.
/// The router waits for this before evaluating auth-gated redirects
/// so we don't briefly bounce users to the sign-in screen on cold
/// start before their persisted token has been loaded.
bool get isAuthInitialized => authState._initialized;

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,13 +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;
@@ -24,15 +25,54 @@ class Http {
);
}
Future<Map<String, String>> getHeaders() async {
Map<String, String> headers = {'Content-Type': 'application/json'};
var prefs = await SharedPreferences.getInstance();
String token = prefs.getString("token") ?? "";
headers['Authorization'] = 'Bearer $token';
return headers;
}
Future<dynamic> get(String url) async {
Map<String, String> headers = await getHeaders();
Response response;
response = await dio.get(baseUrl + url);
response = await dio.get(baseUrl + url, options: Options(headers: headers));
logger.i(response.data.toString());
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(
@@ -43,4 +83,66 @@ class Http {
logger.i(response.data.toString());
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();
Response response;
response = await dio.put(
baseUrl + url,
data: data,
options: Options(headers: headers),
);
logger.i(response.data.toString());
return response.data;
}
Future<dynamic> delete(String url) async {
Map<String, String> headers = await getHeaders();
Response response;
response = await dio.delete(
baseUrl + url,
options: Options(headers: headers),
);
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,85 +1,32 @@
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:go_router/go_router.dart';
import 'package:qpay/navbar.dart';
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/home/home_screen.dart';
import 'package:qpay/screens/history/history_screen.dart';
import 'package:qpay/screens/integration/integration_screen.dart';
import 'package:qpay/screens/onboarding/complete_screen.dart';
import 'package:qpay/screens/onboarding/landing/landing_screen.dart';
import 'package:qpay/screens/onboarding/login/login_screen.dart';
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
import 'package:qpay/screens/onboarding/password/password_screen.dart';
import 'package:qpay/screens/onboarding/phone/phone_screen.dart';
import 'package:qpay/screens/onboarding/register/register_screen.dart';
import 'package:qpay/screens/onboarding/verify/verify_screen.dart';
import 'package:qpay/screens/pay/pay_screen.dart';
import 'package:qpay/screens/poll/poll_screen.dart';
import 'package:qpay/screens/splash_screen.dart';
import 'package:qpay/screens/receipt/receipt_screen.dart';
import 'package:qpay/screens/recipient/recipients_screen.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/profile_screen.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;
}
}
/// Tracks whether the splash animation is complete so that
/// GoRouter's redirect can show the splash first, then release
/// navigation to the originally intended URL.
class SplashState extends ChangeNotifier {
bool _complete = false;
/// The route the user originally intended to visit before being
/// redirected to /splash. Set by the redirect callback on first
/// invocation.
String? _intendedRoute;
bool get complete => _complete;
String? get intendedRoute => _intendedRoute;
/// Called by the redirect to store the intended destination and
/// return the splash route.
String? guard(String attemptedRoute) {
if (_complete) return null;
// Avoid saving the splash route itself.
if (attemptedRoute == '/splash') return null;
_intendedRoute = attemptedRoute;
return '/splash';
}
void finish() {
_complete = true;
notifyListeners();
}
}
final SplashState splashState = SplashState();
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
// them to the sign-in screen until the first refresh kicked in.
await authState.initialize();
runApp(
MultiProvider(
providers: [
@@ -89,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(),
),
@@ -110,150 +65,7 @@ class _MyAppState extends State<MyApp> {
theme: AppTheme.lightTheme,
themeMode: ThemeMode.light,
debugShowCheckedModeBanner: false,
routerConfig: GoRouter(
initialLocation: '/',
refreshListenable: splashState,
errorBuilder: (context, state) => Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Page not found', style: TextStyle(fontSize: 20)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.go('/'),
child: const Text('Go Home'),
),
],
),
),
),
redirect: (context, state) {
// Before splash is complete: redirect everything to /splash,
// but save the original intended destination.
final guardResult = splashState.guard(state.uri.path);
if (guardResult != null) return guardResult;
// Once splash is complete: if we're still on /splash, navigate
// to the intended destination (or home as fallback).
if (splashState.complete && state.uri.path == '/splash') {
return splashState.intendedRoute ?? '/';
}
return null;
},
routes: [
// Splash route sits outside the ShellRoute so it renders
// without the bottom nav bar or side rail.
GoRoute(
path: '/splash',
builder: (context, state) => const SplashScreen(),
),
ShellRoute(
builder: (context, state, child) {
final location = GoRouterState.of(context).uri.toString();
if (location.startsWith('/onboarding/')) {
return child;
}
return ScaffoldWithNavBar(child: child);
},
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/home',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/make-payment',
builder: (context, state) => const PayScreen(),
),
GoRoute(
path: '/confirm',
builder: (context, state) => const ConfirmScreen(),
),
GoRoute(
path: '/gateway',
builder: (context, state) => const GatewayScreen(),
),
GoRoute(
path: '/gateway-web',
builder: (context, state) => const GatewayWebScreen(),
),
GoRoute(
path: '/gateway-redirect',
builder: (context, state) => const GatewayRedirectScreen(),
),
GoRoute(
path: '/poll/:id',
builder: (context, state) => PollScreen(
transactionId: state.pathParameters['id'],
),
),
GoRoute(
path: '/poll',
builder: (context, state) => const PollScreen(),
),
GoRoute(
path: '/receipt',
builder: (context, state) => const ReceiptScreen(),
),
GoRoute(
path: '/receipt/:transactionId',
builder: (context, state) => ReceiptScreen(
transactionId: state.pathParameters['transactionId'],
),
),
GoRoute(
path: '/integration',
builder: (context, state) => const IntegrationScreen(),
),
GoRoute(
path: '/recipients',
builder: (context, state) => const RecipientsScreen(),
),
GoRoute(
path: '/history',
builder: (context, state) => const HistoryScreen(),
),
GoRoute(
path: '/profile',
builder: (context, state) => const ProfileScreen(),
),
GoRoute(
path: '/onboarding/landing',
builder: (context, state) => const LandingScreen(),
),
GoRoute(
path: '/onboarding/register',
builder: (context, state) => const RegisterScreen(),
),
GoRoute(
path: '/onboarding/phone',
builder: (context, state) => const PhoneScreen(),
),
GoRoute(
path: '/onboarding/verify',
builder: (context, state) => const VerifyScreen(),
),
GoRoute(
path: '/onboarding/password',
builder: (context, state) => const PasswordScreen(),
),
GoRoute(
path: '/onboarding/login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/onboarding/complete',
builder: (context, state) => const CompleteScreen(),
),
],
),
],
),
routerConfig: buildRouter(),
);
}
}
}

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;
@@ -18,4 +14,4 @@ class ApiResponse<T> {
factory ApiResponse.failure(String error, {int? statusCode}) {
return ApiResponse(error: error, statusCode: statusCode);
}
}
}

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
import 'models/responsive_policy.dart';
@@ -14,7 +15,6 @@ class ScaffoldWithNavBar extends StatefulWidget {
}
class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
bool _isLoggedIn = false;
late String initials;
@override
@@ -28,7 +28,6 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
if (prefs.getString("token") != null) {
setState(() {
_isLoggedIn = true;
initials = prefs.getString("initials")!;
});
}
@@ -43,22 +42,47 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
body: SafeArea(
child: Row(
children: [
NavigationRail(
extended: true,
leading: Image(
image: AssetImage('assets/velocity.png'),
width: 150
SizedBox(
width: 256,
child: Column(
children: [
Expanded(
child: NavigationRail(
extended: true,
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,
indicatorColor: Theme.of(
context,
).colorScheme.primary.withAlpha(30),
onDestinationSelected: (index) {
_handleTap(index, context);
},
selectedIndex: _calculateSelectedIndex(context),
destinations: _buildNavigationRailDestinations(),
),
),
Divider(
height: 1,
thickness: 1,
color: Theme.of(
context,
).colorScheme.primary.withAlpha(30),
),
_buildFooterLinks(context),
],
),
trailing: _buildLogin(context),
labelType: NavigationRailLabelType.none,
indicatorColor: Theme.of(
context,
).colorScheme.primary.withAlpha(30),
onDestinationSelected: (index) {
_handleTap(index, context);
},
selectedIndex: _calculateSelectedIndex(context),
destinations: _buildNavigationRailDestinations(),
),
VerticalDivider(
thickness: 1,
@@ -121,86 +145,139 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
"selectedIcon": Icon(Icons.home),
"label": 'Home',
},
{
"icon": Icon(Icons.group_outlined),
"selectedIcon": Icon(Icons.group),
"label": 'Groups',
},
{
"icon": Icon(Icons.history_outlined),
"selectedIcon": Icon(Icons.history),
"label": 'History',
},
{
"icon": Icon(Icons.more_horiz),
"selectedIcon": Icon(Icons.more_horiz),
"label": 'More',
},
];
}
Widget _buildLogin(BuildContext context) {
if(_isLoggedIn) {
return SizedBox();
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 20),
child: Column(
children: [
Container(
width: 220,
decoration: BoxDecoration(
border: Border.all(color: Colors.black87.withAlpha(20)),
borderRadius: BorderRadius.circular(12),
),
padding: EdgeInsets.symmetric(vertical: 10, horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 10,
children: [
Text(
'Register or Login for more features',
style: TextStyle(fontSize: 16),
),
InkWell(
onTap: () {
context.push('/onboarding/landing');
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text('Get Started', style: TextStyle(fontSize: 14)),
),
),
],
),
),
SizedBox(height: 10),
],
),
);
}
void _handleTap(int index, BuildContext context) {
switch (index) {
case 0:
context.go('/home');
break;
// case 1:
// context.go('/recipients');
// break;
case 1:
context.go('/groups');
break;
case 2:
context.go('/history');
break;
// case 2:
// context.go('/profile');
// break;
case 3:
context.go('/more');
break;
}
}
int _calculateSelectedIndex(BuildContext context) {
final String location = GoRouterState.of(context).uri.path;
// if (location.startsWith('/recipients')) return 1;
if (location.startsWith('/history')) return 1;
// if (location.startsWith('/profile')) return 2;
if (location.startsWith('/groups')) return 1;
if (location.startsWith('/history')) return 2;
if (location.startsWith('/more') ||
location.startsWith('/users') ||
location.startsWith('/uptime'))
return 3;
return 0;
}
/// Footer link entries shown at the bottom of the web navigation rail.
/// Each entry pairs the visible label with the external URL it should
/// open when tapped.
static const List<Map<String, String>> _footerLinkEntries = [
{'label': 'About', 'url': 'https://velocityafrica.net'},
{'label': 'FAQs', 'url': 'https://velocityafrica.net'},
{'label': 'Privacy', 'url': 'https://qantra.co.zw/privacy'},
{'label': 'Terms', 'url': 'https://qantra.co.zw/terms'},
];
/// Opens the supplied [url] in an external browser using
/// `url_launcher`. Errors are swallowed because the navbar footer
/// is non-critical UI.
Future<void> _openExternalLink(String url) async {
final uri = Uri.tryParse(url);
if (uri == null) return;
try {
final launched = await launchUrl(
uri,
mode: LaunchMode.externalApplication,
);
if (!launched && mounted) {
await launchUrl(uri, mode: LaunchMode.platformDefault);
}
} catch (_) {
// Intentionally silent — footer links are best-effort.
}
}
/// Builds the footer section at the bottom of the web navigation rail.
/// Renders the About / FAQs / Privacy / Terms links (each opening
/// the configured external URL) followed by a copyright line.
Widget _buildFooterLinks(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final mutedTextColor = isDark ? Colors.white60 : Colors.black54;
final linkTextColor = isDark ? Colors.white : Colors.black87;
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Wrap(
spacing: 6,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (var i = 0; i < _footerLinkEntries.length; i++) ...[
InkWell(
onTap: () => _openExternalLink(_footerLinkEntries[i]['url']!),
borderRadius: BorderRadius.circular(4),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 2,
),
child: Text(
_footerLinkEntries[i]['label']!,
style: TextStyle(
fontSize: 12,
color: linkTextColor,
fontWeight: FontWeight.w500,
),
),
),
),
if (i != _footerLinkEntries.length - 1)
Text(
'·',
style: TextStyle(
fontSize: 12,
color: mutedTextColor,
fontWeight: FontWeight.bold,
),
),
],
],
),
const SizedBox(height: 8),
Text(
'© 2026 Velocity Africa. All rights reserved.',
style: TextStyle(fontSize: 10, color: mutedTextColor),
),
],
),
);
}
}

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();
}
}

318
lib/routes.dart Normal file
View File

@@ -0,0 +1,318 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:qpay/auth_state.dart';
import 'package:qpay/navbar.dart';
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/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';
import 'package:qpay/screens/home/home_screen.dart';
import 'package:qpay/screens/integration/integration_screen.dart';
import 'package:qpay/screens/onboarding/complete_screen.dart';
import 'package:qpay/screens/onboarding/landing/landing_screen.dart';
import 'package:qpay/screens/onboarding/login/login_screen.dart';
import 'package:qpay/screens/onboarding/phone/phone_screen.dart';
import 'package:qpay/screens/onboarding/verify/verify_screen.dart';
import 'package:qpay/screens/pay/pay_screen.dart';
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/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
/// navigation to the originally intended URL.
class SplashState extends ChangeNotifier {
bool _complete = false;
/// The route the user originally intended to visit before being
/// redirected to /splash. Set by the redirect callback on first
/// invocation.
String? _intendedRoute;
bool get complete => _complete;
String? get intendedRoute => _intendedRoute;
/// Called by the redirect to store the intended destination and
/// return the splash route.
String? guard(String attemptedRoute) {
if (_complete) return null;
// Avoid saving the splash route itself.
if (attemptedRoute == '/splash') return null;
_intendedRoute = attemptedRoute;
return '/splash';
}
void finish() {
_complete = true;
notifyListeners();
}
}
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', '/users'];
bool _isAuthProtected(String path) {
for (final prefix in _authProtectedRoutePrefixes) {
if (path == prefix || path.startsWith('$prefix/')) {
return true;
}
}
return false;
}
/// Builds the application's [GoRouter] instance.
///
/// The router is responsible for:
/// - Showing the splash screen on cold start and replaying the
/// originally intended route when the animation finishes.
/// - Hosting the [ShellRoute] that adds the [ScaffoldWithNavBar]
/// to every authenticated screen (the onboarding flow renders
/// without the shell so the UI stays full-bleed).
/// - Enforcing the auth wall around protected routes (currently the
/// groups flow). Unauthenticated users are bounced to the sign-in
/// screen, then sent back to their original destination after a
/// successful sign-in.
GoRouter buildRouter() {
return GoRouter(
initialLocation: '/',
// React to both the splash completion and auth state changes so
// protected routes are re-evaluated as the user signs in or out.
refreshListenable: Listenable.merge([splashState, authState]),
errorBuilder: (context, state) => Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Page not found', style: TextStyle(fontSize: 20)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.go('/'),
child: const Text('Go Home'),
),
],
),
),
),
redirect: (context, state) {
// Before splash is complete: redirect everything to /splash,
// but save the original intended destination.
final guardResult = splashState.guard(state.uri.path);
if (guardResult != null) return guardResult;
// Once splash is complete we can start evaluating auth rules.
// We also wait for the initial auth state read to complete so
// a persisted token isn't accidentally ignored on cold start.
if (splashState.complete && isAuthInitialized) {
final path = state.uri.path;
// If the user has just signed in and we still have a pending
// intended route, take them there instead of leaving them
// stuck on the sign-in screen.
if (path == '/onboarding/login' && authState.isLoggedIn) {
final intended = authState.consumeIntendedRoute();
if (intended != null && intended != '/onboarding/login') {
return intended;
}
}
// Auth wall: any protected route requires a valid token.
if (_isAuthProtected(path) && !authState.isLoggedIn) {
return authState.requireAuth(path);
}
// Splash → workspace selector (which may auto-redirect to home
// if only one workspace exists).
if (path == '/splash') {
return '/workspace';
}
}
return null;
},
routes: [
// Splash route sits outside the ShellRoute so it renders
// without the bottom nav bar or side rail.
GoRoute(
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/') || 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',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/make-payment',
builder: (context, state) => const PayScreen(),
),
GoRoute(
path: '/confirm',
builder: (context, state) => const ConfirmScreen(),
),
GoRoute(
path: '/gateway',
builder: (context, state) => const GatewayScreen(),
),
GoRoute(
path: '/gateway-web',
builder: (context, state) => const GatewayWebScreen(),
),
GoRoute(
path: '/gateway-redirect',
builder: (context, state) => const GatewayRedirectScreen(),
),
GoRoute(
path: '/poll/:id',
builder: (context, state) =>
PollScreen(transactionId: state.pathParameters['id']),
),
GoRoute(
path: '/poll',
builder: (context, state) => const PollScreen(),
),
GoRoute(
path: '/receipt',
builder: (context, state) => const ReceiptScreen(),
),
GoRoute(
path: '/receipt/:transactionId',
builder: (context, state) => ReceiptScreen(
transactionId: state.pathParameters['transactionId'],
),
),
GoRoute(
path: '/integration',
builder: (context, state) => const IntegrationScreen(),
),
GoRoute(
path: '/recipients',
builder: (context, state) => const RecipientsScreen(),
),
GoRoute(
path: '/history',
builder: (context, state) => const HistoryScreen(),
),
GoRoute(
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(),
),
GoRoute(
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) {
final group = state.extra as RecipientGroup;
return GroupDetailScreen(group: group);
},
),
GoRoute(
path: '/groups/:groupId/batches/create',
builder: (context, state) {
final group = state.extra as RecipientGroup;
return BatchCreateScreen(group: group);
},
),
GoRoute(
path: '/groups/batches/:batchId',
builder: (context, state) {
final batchId = state.pathParameters['batchId']!;
return BatchDetailScreen(batchId: batchId);
},
),
GoRoute(
path: '/groups/:groupId/batches/:batchId/items/:itemId',
builder: (context, state) {
final item = state.extra as GroupBatchItem;
return BatchItemScreen(item: item);
},
),
GoRoute(
path: '/onboarding/landing',
builder: (context, state) => const LandingScreen(),
),
GoRoute(
path: '/onboarding/phone',
builder: (context, state) => const PhoneScreen(),
),
GoRoute(
path: '/onboarding/verify',
builder: (context, state) => const VerifyScreen(),
),
GoRoute(
path: '/onboarding/login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/onboarding/complete',
builder: (context, state) => const CompleteScreen(),
),
],
),
],
);
}

View File

@@ -0,0 +1,149 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:qpay/screens/accounts/models/account_model.dart';
import 'package:qpay/screens/accounts/models/statement_model.dart';
class AccountProvider extends ChangeNotifier {
final Http http = Http();
AccountProvider();
AccountModel? _account;
StatementModel? _statement;
bool _isLoadingAccount = false;
bool _isLoadingStatement = false;
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;
/// 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/$workspaceId/balance/$currency',
);
_account = AccountModel.fromJson(response);
_balances[currency.toUpperCase()] = _account;
_isLoadingAccount = false;
notifyListeners();
return ApiResponse.success(_account!);
} on DioException catch (e, s) {
logger.e(s);
_isLoadingAccount = false;
_accountError = 'Failed to fetch account. Please try again.';
notifyListeners();
return ApiResponse.failure(_accountError!);
} catch (e, s) {
logger.e(e);
logger.e(s);
_isLoadingAccount = false;
_accountError = 'An unexpected error occurred. Please try again.';
notifyListeners();
return ApiResponse.failure(_accountError!);
}
}
/// 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 workspaceId,
required String currency,
required String startDate,
required String endDate,
}) async {
_isLoadingStatement = true;
_statementError = null;
notifyListeners();
try {
Map<String, dynamic> response = await http.get(
'/accounts/$workspaceId/statement/$currency'
'?startDate=$startDate&endDate=$endDate',
);
_statement = StatementModel.fromJson(response);
_isLoadingStatement = false;
notifyListeners();
return ApiResponse.success(_statement!);
} on DioException catch (e, s) {
logger.e(s);
_isLoadingStatement = false;
_statementError = 'Failed to fetch statement. Please try again.';
notifyListeners();
return ApiResponse.failure(_statementError!);
} catch (e, s) {
logger.e(s);
_isLoadingStatement = false;
_statementError = 'An unexpected error occurred. Please try again.';
notifyListeners();
return ApiResponse.failure(_statementError!);
}
}
void clearAccount() {
_account = null;
_accountError = null;
notifyListeners();
}
void clearStatement() {
_statement = null;
_statementError = null;
notifyListeners();
}
}

View File

@@ -0,0 +1,67 @@
import 'package:json_annotation/json_annotation.dart';
part 'account_model.g.dart';
@JsonSerializable()
class CurrencyModel {
final String id;
final String? createdAt;
final String name;
final String symbol;
final String code;
final double rate;
final bool defaultCurrency;
CurrencyModel({
required this.id,
this.createdAt,
required this.name,
required this.symbol,
required this.code,
required this.rate,
required this.defaultCurrency,
});
factory CurrencyModel.fromJson(Map<String, dynamic> json) =>
_$CurrencyModelFromJson(json);
Map<String, dynamic> toJson() => _$CurrencyModelToJson(this);
}
@JsonSerializable()
class AccountModel {
final String name;
final String description;
final String accountNumber;
final String alternateAccountNumber;
final String type;
final String category;
final String ledger;
final double balance;
final String partyType;
final String party;
final CurrencyModel currency;
final String customerStringId;
final String companyStringId;
AccountModel({
required this.name,
required this.description,
required this.accountNumber,
required this.alternateAccountNumber,
required this.type,
required this.category,
required this.ledger,
required this.balance,
required this.partyType,
required this.party,
required this.currency,
required this.customerStringId,
required this.companyStringId,
});
factory AccountModel.fromJson(Map<String, dynamic> json) =>
_$AccountModelFromJson(json);
Map<String, dynamic> toJson() => _$AccountModelToJson(this);
}

View File

@@ -0,0 +1,62 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'account_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CurrencyModel _$CurrencyModelFromJson(Map<String, dynamic> json) =>
CurrencyModel(
id: json['id'] as String,
createdAt: json['createdAt'] as String?,
name: json['name'] as String,
symbol: json['symbol'] as String,
code: json['code'] as String,
rate: (json['rate'] as num).toDouble(),
defaultCurrency: json['defaultCurrency'] as bool,
);
Map<String, dynamic> _$CurrencyModelToJson(CurrencyModel instance) =>
<String, dynamic>{
'id': instance.id,
'createdAt': instance.createdAt,
'name': instance.name,
'symbol': instance.symbol,
'code': instance.code,
'rate': instance.rate,
'defaultCurrency': instance.defaultCurrency,
};
AccountModel _$AccountModelFromJson(Map<String, dynamic> json) => AccountModel(
name: json['name'] as String,
description: json['description'] as String,
accountNumber: json['accountNumber'] as String,
alternateAccountNumber: json['alternateAccountNumber'] as String,
type: json['type'] as String,
category: json['category'] as String,
ledger: json['ledger'] as String,
balance: (json['balance'] as num).toDouble(),
partyType: json['partyType'] as String,
party: json['party'] as String,
currency: CurrencyModel.fromJson(json['currency'] as Map<String, dynamic>),
customerStringId: json['customerStringId'] as String,
companyStringId: json['companyStringId'] as String,
);
Map<String, dynamic> _$AccountModelToJson(AccountModel instance) =>
<String, dynamic>{
'name': instance.name,
'description': instance.description,
'accountNumber': instance.accountNumber,
'alternateAccountNumber': instance.alternateAccountNumber,
'type': instance.type,
'category': instance.category,
'ledger': instance.ledger,
'balance': instance.balance,
'partyType': instance.partyType,
'party': instance.party,
'currency': instance.currency,
'customerStringId': instance.customerStringId,
'companyStringId': instance.companyStringId,
};

View File

@@ -0,0 +1,63 @@
import 'package:json_annotation/json_annotation.dart';
part 'statement_model.g.dart';
@JsonSerializable()
class TransactionModel {
final String name;
final String narration;
final String party;
final String company;
final String postingDate;
final double amount;
final String account;
final String accountName;
final String currency;
final String type;
final double balance;
TransactionModel({
required this.name,
required this.narration,
required this.party,
required this.company,
required this.postingDate,
required this.amount,
required this.account,
required this.accountName,
required this.currency,
required this.type,
required this.balance,
});
factory TransactionModel.fromJson(Map<String, dynamic> json) =>
_$TransactionModelFromJson(json);
Map<String, dynamic> toJson() => _$TransactionModelToJson(this);
}
@JsonSerializable()
class StatementModel {
final int startBalance;
final double endBalance;
final List<TransactionModel> transactions;
final String accountName;
final String accountNumber;
final String startDate;
final String endDate;
StatementModel({
required this.startBalance,
required this.endBalance,
required this.transactions,
required this.accountName,
required this.accountNumber,
required this.startDate,
required this.endDate,
});
factory StatementModel.fromJson(Map<String, dynamic> json) =>
_$StatementModelFromJson(json);
Map<String, dynamic> toJson() => _$StatementModelToJson(this);
}

View File

@@ -0,0 +1,61 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'statement_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TransactionModel _$TransactionModelFromJson(Map<String, dynamic> json) =>
TransactionModel(
name: json['name'] as String,
narration: json['narration'] as String,
party: json['party'] as String,
company: json['company'] as String,
postingDate: json['postingDate'] as String,
amount: (json['amount'] as num).toDouble(),
account: json['account'] as String,
accountName: json['accountName'] as String,
currency: json['currency'] as String,
type: json['type'] as String,
balance: (json['balance'] as num).toDouble(),
);
Map<String, dynamic> _$TransactionModelToJson(TransactionModel instance) =>
<String, dynamic>{
'name': instance.name,
'narration': instance.narration,
'party': instance.party,
'company': instance.company,
'postingDate': instance.postingDate,
'amount': instance.amount,
'account': instance.account,
'accountName': instance.accountName,
'currency': instance.currency,
'type': instance.type,
'balance': instance.balance,
};
StatementModel _$StatementModelFromJson(Map<String, dynamic> json) =>
StatementModel(
startBalance: (json['startBalance'] as num).toInt(),
endBalance: (json['endBalance'] as num).toDouble(),
transactions: (json['transactions'] as List<dynamic>)
.map((e) => TransactionModel.fromJson(e as Map<String, dynamic>))
.toList(),
accountName: json['accountName'] as String,
accountNumber: json['accountNumber'] as String,
startDate: json['startDate'] as String,
endDate: json['endDate'] as String,
);
Map<String, dynamic> _$StatementModelToJson(StatementModel instance) =>
<String, dynamic>{
'startBalance': instance.startBalance,
'endBalance': instance.endBalance,
'transactions': instance.transactions,
'accountName': instance.accountName,
'accountNumber': instance.accountNumber,
'startDate': instance.startDate,
'endDate': instance.endDate,
};

View File

@@ -0,0 +1,526 @@
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 {
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> {
bool _isLoggedIn = false;
String? _workspaceId;
AccountModel? _usdAccount;
AccountModel? _zwgAccount;
bool _isLoadingUsd = false;
bool _isLoadingZwG = false;
String? _usdError;
String? _zwgError;
bool _showAccountError = false;
@override
void initState() {
super.initState();
_loadAuthStateAndBalances();
}
Future<void> _loadAuthStateAndBalances() async {
final prefs = await SharedPreferences.getInstance();
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(_workspaceId, 'USD');
if (mounted) {
setState(() {
_isLoadingUsd = false;
if (usdResult.isSuccess && usdResult.data != null) {
_usdAccount = usdResult.data;
_usdError = null;
} else {
_usdError = usdResult.error;
}
});
}
// Fetch ZWG balance
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 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) {
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;
});
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
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: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: _buildCompactCard(
theme: theme,
isDark: isDark,
currencyCode: 'USD',
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: 10),
Expanded(
child: _buildCompactCard(
theme: theme,
isDark: isDark,
currencyCode: 'ZWG',
flagAsset: 'zimbabwe.png',
balance: _zwgAccount?.balance,
isLoading: _isLoadingZwG,
error: _zwgError,
isSelected: widget.selectedCurrency == 'ZWG',
gradientColors: [
const Color(0xFF1B5E20),
const Color(0xFF388E3C),
],
),
),
],
),
const SizedBox(height: 6),
Center(
child: TextButton.icon(
onPressed: _isLoadingUsd || _isLoadingZwG
? null
: () {
_refreshBalances();
},
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',
style: TextStyle(
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,
),
),
),
],
),
);
}
// ──────────────────────────────────────────────────────────────
// 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 flagAsset,
double? balance,
required bool isLoading,
String? error,
required bool isSelected,
required List<Color> gradientColors,
}) {
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,
),
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(
'assets/$flagAsset',
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Icon(
Icons.monetization_on_outlined,
size: 12,
color: Colors.white.withValues(alpha: 0.8),
),
),
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
currencyCode,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Colors.white,
letterSpacing: 0.5,
),
),
),
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,
),
),
],
],
),
),
);
}
String _formatBalance(double balance) {
final formatter = NumberFormat('#,##0.00', 'en_US');
return '\$${formatter.format(balance)}';
}
}

View File

@@ -1,9 +1,11 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:provider/provider.dart';
import 'package:qpay/models/api_response.dart';
import 'package:qpay/screens/transaction_controller.dart';
import 'package:qpay/widgets/app_snack_bar.dart';
import '../../http/http.dart';
@@ -43,13 +45,7 @@ class ConfirmController extends ChangeNotifier {
void _showErrorSnackBar(String? message) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message.toString()),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.deepOrange,
),
);
AppSnackBar.showError(context, message.toString());
});
}
@@ -59,19 +55,21 @@ class ConfirmController extends ChangeNotifier {
notifyListeners();
// todo: find out if we still need to update these fields
transactionController
transactionController.model.formData = transactionController
.model
.formData = transactionController.model.formData.copyWith(
type: "REQUEST",
trace: transactionController.model.confirmationData['trace'],
id: transactionController.model.confirmationData['id'],
authType: transactionController.model.selectedPaymentProcessor.authType,
);
.formData
.copyWith(
type: "REQUEST",
trace: transactionController.model.confirmationData['trace'],
id: transactionController.model.confirmationData['id'],
authType:
transactionController.model.selectedPaymentProcessor.authType,
);
dynamic workflowResponse = await http.get(
'/public/transaction/request/'
'${transactionController.model.confirmationData['id']}/'
'${transactionController.model.selectedPaymentProcessor.authType}'
'${transactionController.model.confirmationData['id']}/'
'${transactionController.model.selectedPaymentProcessor.authType}',
);
logger.i(workflowResponse.toString());
@@ -87,7 +85,9 @@ class ConfirmController extends ChangeNotifier {
} else {
model.status = response['status'];
model.errorMessage = response['errorMessage'];
return ApiResponse.failure(response['errorMessage'] ?? "Transaction failed");
return ApiResponse.failure(
response['errorMessage'] ?? "Transaction failed",
);
}
} catch (e) {
logger.e(e);
@@ -130,6 +130,53 @@ class ConfirmController extends ChangeNotifier {
return ApiResponse.failure("Polling cancelled");
}
Future<ApiResponse<Map<String, dynamic>>> updateVelocityTransactionOtp(
String id,
Map<String, dynamic> transaction,
) async {
try {
model.isLoading = true;
notifyListeners();
dynamic response = await http.put(
'/public/transaction/$id/velocity-otp',
transaction,
);
model.isLoading = false;
notifyListeners();
if (response['status'] == 'SUCCESS') {
return ApiResponse.success(Map<String, dynamic>.from(response));
} else {
model.errorMessage =
response['errorMessage'] ??
"Problem updating transaction. Please try again or contact support";
return ApiResponse.failure(model.errorMessage!);
}
} on DioException catch (e, s) {
if (kDebugMode) {
logger.e(e);
logger.e(s);
}
model.isLoading = false;
notifyListeners();
return ApiResponse.failure(
"Network error. Please try again or contact support",
);
} catch (e, s) {
if (kDebugMode) {
logger.e(e);
logger.e(s);
}
model.isLoading = false;
notifyListeners();
return ApiResponse.failure(
"Problem updating that transaction. Please try again or contact support",
);
}
}
@override
void dispose() {
// TODO: implement dispose

View File

@@ -1,9 +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});
@@ -65,17 +71,11 @@ class _ConfirmScreenState extends State<ConfirmScreen>
final response = await controller.doTransaction();
if (!response.isSuccess) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
controller.model.errorMessage ??
response.error ??
"Transaction failed",
),
behavior: SnackBarBehavior.floating,
width: 600,
backgroundColor: Colors.deepOrange,
),
AppSnackBar.showError(
context,
controller.model.errorMessage ??
response.error ??
"Transaction failed",
);
}
return;
@@ -83,6 +83,41 @@ class _ConfirmScreenState extends State<ConfirmScreen>
if (!mounted) return;
// Check if the selected payment processor is a wallet (e.g. Velocity)
if (controller
.transactionController
.model
.selectedPaymentProcessor
.label ==
'WALLET') {
final otp = await _showOtpBottomSheet();
if (otp == null) return; // User cancelled or dismissed the bottom sheet
if (!mounted) return;
final transactionId =
controller.transactionController.model.confirmationData['id'];
final otpResponse = await controller.updateVelocityTransactionOtp(
transactionId,
{'verificationCode': otp},
);
if (!otpResponse.isSuccess) {
if (context.mounted) {
AppSnackBar.showError(
context,
controller.model.errorMessage ??
otpResponse.error ??
"OTP verification failed",
);
}
return;
}
if (!mounted) return;
}
// Proceed with normal flow after OTP step (or skip if not WALLET)
if (controller
.transactionController
.model
@@ -90,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');
}
@@ -104,6 +155,304 @@ class _ConfirmScreenState extends State<ConfirmScreen>
}
}
Future<String?> _showOtpBottomSheet() async {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final otpController = TextEditingController();
final formKey = GlobalKey<FormState>();
return showModalBottomSheet<String>(
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: Form(
key: formKey,
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: theme.colorScheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.verified_user_rounded,
size: 28,
color: theme.colorScheme.primary,
),
),
),
const SizedBox(height: 16),
Text(
'OTP Verification',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'An OTP has been sent to your registered mobile number/email. Please enter it below to proceed.',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.grey.shade500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
TextFormField(
controller: otpController,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
maxLength: 6,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: 8,
color: isDark ? Colors.white : Colors.black87,
),
decoration: InputDecoration(
counterText: '',
hintText: '------',
hintStyle: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: 8,
color: Colors.grey.shade400,
),
filled: true,
fillColor: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 2,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.red.shade400),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter the OTP';
}
if (value.trim().length < 4) {
return 'OTP must be at least 4 digits';
}
return null;
},
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
if (formKey.currentState?.validate() ?? false) {
Navigator.of(
sheetContext,
).pop(otpController.text.trim());
}
},
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(
'Submit',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
),
),
);
},
);
}
/// 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(
@@ -172,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
@@ -393,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

@@ -3,14 +3,18 @@ import 'dart:async';
import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:qpay/screens/transaction_controller.dart';
import 'package:qpay/widgets/app_snack_bar.dart';
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 {
@@ -30,13 +34,7 @@ class GatewayController extends ChangeNotifier {
void _showErrorSnackBar(String? message) {
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message.toString()),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.deepOrange,
),
);
AppSnackBar.showError(context, message.toString());
});
}
@@ -54,19 +52,24 @@ class GatewayController extends ChangeNotifier {
Future<ApiResponse<Map<String, dynamic>>> poll(String uid) async {
startLoading();
try {
dynamic workflowResponse =
await http.get('/public/transaction/poll/$uid');
dynamic workflowResponse = await http.get(
'/public/transaction/poll/$uid',
);
logger.i(workflowResponse.toString());
dynamic response = workflowResponse['body'];
model.status = response['status'];
model.status = response['pollingStatus'];
transactionController.model.paymentStatus = response['paymentStatus'];
finishLoading();
if (model.status == 'SUCCESS') {
transactionController.updateReceiptData(response);
} else {
model.status = 'FAILED';
model.errorMessage = response['errorMessage'];
return ApiResponse.failure(response['errorMessage']);
}
notifyListeners();
return ApiResponse.success(Map<String, dynamic>.from(response));
@@ -83,38 +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 = 30;
// 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
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();
@@ -123,7 +134,8 @@ class GatewayController extends ChangeNotifier {
// Alternative method using Timer instead of while loop
Future<ApiResponse<Map<String, dynamic>>> pollTransactionWithTimer(
String uid) async {
String uid,
) async {
_pollTimer = Timer.periodic(const Duration(seconds: 5), (timer) async {
if (model.isCancelled) {
timer.cancel();
@@ -150,7 +162,8 @@ class GatewayController extends ChangeNotifier {
// This returns immediately; the caller should use the timer callback approach.
// Consider refactoring this to use poll(String uid) directly for a consistent API.
return ApiResponse.failure(
"Polling via timer started; use a different flow for the result");
"Polling via timer started; use a different flow for the result",
);
}
@override
@@ -159,4 +172,4 @@ class GatewayController extends ChangeNotifier {
_pollTimer?.cancel(); // Cancel timer if it exists
super.dispose();
}
}
}

View File

@@ -1,7 +1,8 @@
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';
class GatewayScreen extends StatefulWidget {
@@ -55,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/");
@@ -69,15 +70,11 @@ class _GatewayScreenState extends State<GatewayScreen> {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
"Payment was successful, please wait while we process your order.",
),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.black87,
duration: const Duration(seconds: 10),
),
AppSnackBar.show(
context,
'Payment was successful, please wait while we process your order.',
backgroundColor: Colors.black87,
duration: const Duration(seconds: 10),
);
setState(() {

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

@@ -0,0 +1,755 @@
import 'package:flutter/material.dart';
import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:qpay/screens/groups/models/group_batch_model.dart';
import 'package:qpay/models/pageable_model.dart';
import 'package:qpay/screens/home/home_controller.dart';
import 'package:qpay/screens/pay/pay_controller.dart';
import 'package:qpay/screens/transactions/transaction_model.dart';
import '../models/batch_item_update_model.dart';
class BatchScreenModel {
List<GroupBatch> batches = [];
List<GroupBatchItem> batchItems = [];
GroupBatch? currentBatch;
List<BillProvider> providers = [];
List<PaymentProcessor> processors = [];
BillProvider? selectedProvider;
PaymentProcessor? selectedProcessor;
bool isLoading = false;
bool isLoadingMore = false;
bool isActing = false;
String? errorMessage;
int currentPage = 0;
int totalPages = 0;
int totalElements = 0;
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 {
final BatchScreenModel model = BatchScreenModel();
final Http http = Http();
BuildContext context;
bool _disposed = false;
BatchController(this.context);
@override
void dispose() {
_disposed = true;
super.dispose();
}
dynamic _unwrap(dynamic response) {
if (response is Map && response.containsKey('body')) {
return response['body'];
}
return response;
}
String buildQueryParameters(Map<String, String> params) {
if (params.isEmpty) {
return '';
}
String query = '';
params.forEach((key, value) {
query += '$key=$value&';
});
return query.substring(0, query.length - 1);
}
Future<ApiResponse<List<BillProvider>>> loadProviders(String currency) async {
try {
final raw = await http.get(
'/public/providers?currency=$currency&sort=priority,asc',
);
final response = _unwrap(raw);
final List<dynamic> content;
if (response is Map && response.containsKey('content')) {
content = response['content'] as List<dynamic>;
} else if (response is List) {
content = response;
} else {
content = [];
}
model.providers = content
.map((e) => BillProvider.fromJson(e as Map<String, dynamic>))
.toList();
if (model.providers.isNotEmpty) {
model.selectedProvider = model.providers.first;
}
if (!_disposed) notifyListeners();
return ApiResponse.success(model.providers);
} catch (e) {
logger.e(e);
return ApiResponse.failure('Failed to load providers');
}
}
Future<ApiResponse<List<PaymentProcessor>>> loadProcessors(currency) async {
try {
final raw = await http.get(
'/public/payment-processors?currency=$currency',
);
final response = _unwrap(raw);
final List<dynamic> content = response is List
? response
: (response['content'] ?? []);
model.processors = content
.map((e) => PaymentProcessor.fromJson(e as Map<String, dynamic>))
.toList();
if (model.processors.isNotEmpty) {
model.selectedProcessor = model.processors.first;
}
if (!_disposed) notifyListeners();
return ApiResponse.success(model.processors);
} catch (e) {
logger.e(e);
return ApiResponse.failure('Failed to load processors');
}
}
Future<ApiResponse<List<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 = 10,
String? search,
String? status,
String? currency,
}) async {
if (page == 0) {
model.isLoading = true;
model.batches = [];
model.currentPage = 0;
} else {
model.isLoadingMore = true;
}
if (!_disposed) notifyListeners();
try {
final params = <String, String>{
'recipientGroupId': groupId,
'page': page.toString(),
'size': size.toString(),
'sort': 'createdAt,desc',
};
if (search != null && search.isNotEmpty) {
params['description'] = search;
}
if (status != null && status.isNotEmpty) {
params['status'] = status;
}
if (currency != null && currency.isNotEmpty) {
params['currency'] = currency;
}
final raw = await http.get(
'/group-batches?${buildQueryParameters(params)}',
);
final response = _unwrap(raw);
final pageableModel = PageableModel.fromJson(
response as Map<String, dynamic>,
);
final newBatches = pageableModel.content
.map((e) => GroupBatch.fromJson(e as Map<String, dynamic>))
.toList();
if (page == 0) {
model.batches = newBatches;
} else {
model.batches.addAll(newBatches);
}
model.currentPage = pageableModel.number;
model.totalPages = pageableModel.totalPages;
model.totalElements = pageableModel.totalElements;
model.isLoading = false;
model.isLoadingMore = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(model.batches);
} catch (e) {
logger.e(e);
model.errorMessage = e.toString();
if (page == 0) {
model.batches = [];
}
model.isLoading = false;
model.isLoadingMore = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to load batches');
}
}
Future<void> searchBatches(
String groupId, {
String? search,
String? status,
String? currency,
}) async {
model.searchQuery = search ?? '';
model.statusFilter = status;
model.currencyFilter = currency;
await listBatches(
groupId,
page: 0,
size: model.pageSize,
search: search,
status: status,
currency: currency,
);
}
Future<void> loadNextPage(String groupId) async {
if (model.isLoadingMore || model.currentPage + 1 >= model.totalPages) {
return;
}
await listBatches(
groupId,
page: model.currentPage + 1,
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,
);
}
Future<ApiResponse<List<GroupBatchItem>>> listBatchItems(
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(
'/group-batches/items?${buildQueryParameters(params)}',
);
final response = _unwrap(raw);
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();
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 workspaceId,
) async {
model.isActing = true;
if (!_disposed) notifyListeners();
try {
final raw = await http.get(
'/group-batches/$batchId?workspaceId=$workspaceId',
);
final response = _unwrap(raw);
final batch = GroupBatch.fromJson(response as Map<String, dynamic>);
model.currentBatch = batch;
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(batch);
} catch (e) {
logger.e(e);
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to load batch');
}
}
Future<ApiResponse<GroupBatch>> createBatch(CreateBatchRequest req) async {
model.isActing = true;
if (!_disposed) notifyListeners();
try {
final raw = await http.post('/group-batches', req.toJson());
final response = _unwrap(raw);
final batch = GroupBatch.fromJson(response as Map<String, dynamic>);
model.currentBatch = batch;
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(batch);
} catch (e, s) {
logger.e(e);
logger.e(s);
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to create batch');
}
}
Future<ApiResponse<GroupBatch>> updateBatch(
GroupBatchUpdateRequest request,
) async {
model.isActing = true;
if (!_disposed) notifyListeners();
try {
final raw = await http.put(
'/group-batches/${request.id}',
request.toJson(),
);
final response = _unwrap(raw);
final batch = GroupBatch.fromJson(response as Map<String, dynamic>);
// Update the batch in the local list if present
final index = model.batches.indexWhere((b) => b.id == batch.id);
if (index != -1) {
model.batches[index] = batch;
}
model.currentBatch = batch;
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(batch);
} catch (e, s) {
logger.e(e);
logger.e(s);
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to update batch');
}
}
Future<ApiResponse<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 workspaceId,
String authType,
) async {
model.isActing = true;
if (!_disposed) notifyListeners();
try {
final key =
'batch-$batchId-confirm-${DateTime.now().millisecondsSinceEpoch}';
final body = BatchActionRequest(
workspaceId: workspaceId,
authType: authType,
idempotencyKey: key,
);
final raw = await http.post(
'/group-batches/$batchId/confirm',
body.toJson(),
);
final response = _unwrap(raw);
final batch = GroupBatch.fromJson(response as Map<String, dynamic>);
model.currentBatch = batch;
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(batch);
} catch (e) {
logger.e(e);
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to confirm batch');
}
}
Future<ApiResponse<TransactionModel>> requestBatch(
String batchId,
String workspaceId,
) async {
model.isActing = true;
if (!_disposed) notifyListeners();
try {
final key =
'batch-$batchId-request-${DateTime.now().millisecondsSinceEpoch}';
final body = BatchActionRequest(
workspaceId: workspaceId,
idempotencyKey: key,
);
final raw = await http.post(
'/group-batches/$batchId/request',
body.toJson(),
);
final response = _unwrap(raw);
final txn = TransactionModel.fromJson(response as Map<String, dynamic>);
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(txn);
} catch (e, s) {
logger.e(e);
logger.e(s);
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to request batch payment');
}
}
Future<ApiResponse<Map<String, dynamic>>> downloadBatchReport(
String batchId,
String workspaceId,
) async {
model.isActing = true;
if (!_disposed) notifyListeners();
try {
final raw = await http.get(
'/group-batches/$batchId/report?workspaceId=$workspaceId',
);
final response = _unwrap(raw);
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(response as Map<String, dynamic>);
} catch (e, s) {
logger.e(e);
logger.e(s);
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to download batch report');
}
}
Future<ApiResponse<TransactionModel>> pollBatch(
String batchId,
String workspaceId,
) async {
model.isActing = true;
if (!_disposed) notifyListeners();
try {
final key =
'batch-$batchId-poll-${DateTime.now().millisecondsSinceEpoch}';
final body = BatchActionRequest(
workspaceId: workspaceId,
idempotencyKey: key,
);
final raw = await http.post(
'/group-batches/$batchId/poll',
body.toJson(),
);
final response = _unwrap(raw);
final batchTransaction = TransactionModel.fromJson(
response as Map<String, dynamic>,
);
model.isActing = false;
if (!_disposed) notifyListeners();
if (batchTransaction.pollingStatus == 'SUCCESS') {
return ApiResponse.success(batchTransaction);
} else {
return ApiResponse.failure(
response['errorMessage'] ?? "Transaction failed",
);
}
} catch (e, s) {
logger.e(e);
logger.e(s);
model.isActing = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to poll batch');
}
}
Future<void> deleteBatch({
required String batchId,
required String workspaceId,
}) async {
try {
await http.delete('/group-batches/$batchId?workspaceId=$workspaceId');
model.batches.removeWhere((b) => b.id == batchId);
model.selectedBatchIds.remove(batchId);
model.currentBatch = null;
if (!_disposed) notifyListeners();
} catch (e) {
logger.e(e);
model.errorMessage = e.toString();
if (!_disposed) notifyListeners();
}
}
Future<void> deleteSelectedBatches({
required String groupId,
required String workspaceId,
}) async {
final idsToDelete = List<String>.from(model.selectedBatchIds);
model.selectMode = false;
model.selectedBatchIds.clear();
if (!_disposed) notifyListeners();
for (final id in idsToDelete) {
await deleteBatch(batchId: id, workspaceId: workspaceId);
}
// Refresh after deletion
await listBatches(groupId, page: 0);
}
void selectProvider(BillProvider provider) {
model.selectedProvider = provider;
if (!_disposed) notifyListeners();
}
void selectProcessor(PaymentProcessor processor) {
model.selectedProcessor = processor;
if (!_disposed) notifyListeners();
}
void toggleSelectMode() {
model.selectMode = !model.selectMode;
if (!model.selectMode) {
model.selectedBatchIds.clear();
}
if (!_disposed) notifyListeners();
}
void toggleBatchSelection(String batchId) {
if (model.selectedBatchIds.contains(batchId)) {
model.selectedBatchIds.remove(batchId);
} else {
model.selectedBatchIds.add(batchId);
}
if (!_disposed) notifyListeners();
}
}

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