Compare commits
53 Commits
c86c7fca49
...
release/0.
| Author | SHA1 | Date | |
|---|---|---|---|
| ea04dea7bc | |||
| f04fc15fb9 | |||
| dad09202c4 | |||
| c253a69566 | |||
| eed7415ef3 | |||
| 1db6641f2c | |||
| 55921f6ee3 | |||
| 17db608dbc | |||
| eb774b72f6 | |||
| a1a7f3a4d6 | |||
| 3023bc313c | |||
| 78f64c0af6 | |||
| 0201a78f17 | |||
| 664cefcecd | |||
| a31e38765d | |||
| 263c10d2bb | |||
| 57358f6233 | |||
| a6b4a04bd5 | |||
| 9e55ec1097 | |||
| a79cfadf51 | |||
| 5a5aab6956 | |||
| 1866e60518 | |||
| e89c711d00 | |||
| ae2705363a | |||
|
|
62c7f53de0 | ||
|
|
eca22cd6a3 | ||
|
|
101486cb1e | ||
|
|
7de44de068 | ||
|
|
b90d95bc63 | ||
|
|
381b5fbf08 | ||
|
|
bb9dbac2a3 | ||
|
|
f3ae5ca791 | ||
|
|
64eaa38079 | ||
|
|
cc4b02f3c9 | ||
|
|
31f403aa10 | ||
|
|
8b78199992 | ||
|
|
5014b679f5 | ||
|
|
7dc48e06e9 | ||
|
|
d36207bc54 | ||
|
|
77236a11d9 | ||
|
|
202756a6bb | ||
|
|
4386fb6036 | ||
|
|
6af2d4f7b2 | ||
|
|
2bcee59fd7 | ||
|
|
ad79bf2af8 | ||
| b10d233428 | |||
| 0a267bdbb8 | |||
| fc616d6316 | |||
| 2dd790fe6e | |||
| eab2368331 | |||
| 1d8ab2088c | |||
| 7b5b962fe6 | |||
| 59c96d889a |
16
.vscode/launch.json
vendored
@@ -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"
|
||||
]
|
||||
},
|
||||
|
||||
|
||||
13
Dockerfile
@@ -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;"]
|
||||
|
||||
7
No file path - using task_progress only
Normal 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
@@ -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
|
||||
|
||||
160
WORKSPACE_ID_MIGRATION_AUDIT.md
Normal 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 28–36). 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**
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 22 KiB |
BIN
android/app/src/main/play_store_512.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
Before Width: | Height: | Size: 33 KiB |
@@ -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>
|
||||
@@ -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>
|
||||
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 6.0 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 5.7 KiB After Width: | Height: | Size: 3.7 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 3.4 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 696 B |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.4 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 9.2 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 5.6 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_monochrome.png
Normal file
|
After Width: | Height: | Size: 5.6 KiB |
|
Before Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 7.8 KiB After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 26 KiB |
@@ -1,3 +0,0 @@
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#ffffff</color>
|
||||
</resources>
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
Before Width: | Height: | Size: 150 KiB |
BIN
assets/city.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
assets/complete.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
assets/giphy.gif
|
Before Width: | Height: | Size: 92 KiB |
4
assets/group-batch-example.csv
Normal 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
|
||||
|
|
Before Width: | Height: | Size: 3.1 MiB |
BIN
assets/peak.png
|
Before Width: | Height: | Size: 70 KiB |
BIN
assets/signup.png
Normal file
|
After Width: | Height: | Size: 109 KiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 671 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 99 KiB |
4
config.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"APP_ENV": "live",
|
||||
"BASE_URL": "https://payapi.velocityafrica.net/api"
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -1 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
43
ios/Podfile
Normal 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
|
||||
@@ -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>
|
||||
@@ -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
@@ -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
@@ -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';
|
||||
53
lib/config/app_config.dart
Normal 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';
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
244
lib/main.dart
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
39
lib/models/uptime_item_model.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
43
lib/models/user_model.dart
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
229
lib/navbar.dart
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
18
lib/platform/browser_stub.dart
Normal 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();
|
||||
}
|
||||
22
lib/platform/browser_web.dart
Normal 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();
|
||||
}
|
||||
3
lib/platform/url_strategy_stub.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
/// Stub for path URL strategy on non-web platforms.
|
||||
/// Does nothing since URL strategies are web-only.
|
||||
void usePathUrlStrategy() {}
|
||||
6
lib/platform/url_strategy_web.dart
Normal 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();
|
||||
}
|
||||
54
lib/providers/uptime_provider.dart
Normal 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
@@ -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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
149
lib/screens/accounts/account_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
67
lib/screens/accounts/models/account_model.dart
Normal 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);
|
||||
}
|
||||
62
lib/screens/accounts/models/account_model.g.dart
Normal 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,
|
||||
};
|
||||
63
lib/screens/accounts/models/statement_model.dart
Normal 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);
|
||||
}
|
||||
61
lib/screens/accounts/models/statement_model.g.dart
Normal 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,
|
||||
};
|
||||
526
lib/screens/accounts/widgets/account_balance_widget.dart
Normal 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)}';
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
303
lib/screens/contact/contact_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
541
lib/screens/delete_account/delete_account_screen.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(() {
|
||||
|
||||
@@ -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
|
||||
|
||||
755
lib/screens/groups/controllers/batch_controller.dart
Normal 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();
|
||||
}
|
||||
}
|
||||