Compare commits
33 Commits
main
...
3023bc313c
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
14
.vscode/launch.json
vendored
@@ -11,7 +11,19 @@
|
|||||||
"deviceId": "chrome",
|
"deviceId": "chrome",
|
||||||
"args": [
|
"args": [
|
||||||
"--dart-define=APP_ENV=test",
|
"--dart-define=APP_ENV=test",
|
||||||
"--web-port=9005"
|
"--web-port=9005",
|
||||||
|
"--dart-define=BASE_URL=http://localhost:6950/api"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "qpay - live",
|
||||||
|
"request": "launch",
|
||||||
|
"type": "dart",
|
||||||
|
"deviceId": "chrome",
|
||||||
|
"args": [
|
||||||
|
"--dart-define=APP_ENV=live",
|
||||||
|
"--web-port=9005",
|
||||||
|
"--dart-define=BASE_URL=https://payapi.velocityafrica.net/api"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ WORKDIR $APP
|
|||||||
# Run build: 1 - clean, 2 - pub get, 3 - build web
|
# Run build: 1 - clean, 2 - pub get, 3 - build web
|
||||||
RUN flutter clean
|
RUN flutter clean
|
||||||
RUN flutter pub get
|
RUN flutter pub get
|
||||||
RUN flutter build web --dart-define=APP_ENV=live
|
RUN flutter build web \
|
||||||
|
--dart-define=APP_ENV=live \
|
||||||
|
--dart-define=BASE_URL=https://payapi.velocityafrica.net/api \
|
||||||
|
--dart-define=CLIENTID=77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com \
|
||||||
|
--dart-define=SIMULATE_PAYMENT_SUCCESS=false \
|
||||||
|
--dart-define=GATEWAY_SCRIPT_URL=https://na.gateway.mastercard.com/static/checkout/checkout.min.js
|
||||||
|
|
||||||
# once heare the app will be compiled and ready to deploy
|
# once heare the app will be compiled and ready to deploy
|
||||||
|
|
||||||
|
|||||||
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**
|
||||||
|
Before Width: | Height: | Size: 22 KiB |
BIN
android/app/src/main/play_store_512.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
|
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">
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<background android:drawable="@color/ic_launcher_background"/>
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
|
||||||
</adaptive-icon>
|
</adaptive-icon>
|
||||||
@@ -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
|
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
|
# This builtInKotlin flag was added automatically by Flutter migrator
|
||||||
|
android.builtInKotlin=false
|
||||||
|
# This newDsl flag was added automatically by Flutter migrator
|
||||||
|
android.newDsl=false
|
||||||
|
|||||||
@@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||||
id("com.android.application") version "8.7.0" apply false
|
id("com.android.application") version "8.9.1" apply false
|
||||||
// START: FlutterFire Configuration
|
// START: FlutterFire Configuration
|
||||||
id("com.google.gms.google-services") version("4.3.15") apply false
|
id("com.google.gms.google-services") version("4.3.15") apply false
|
||||||
// END: FlutterFire Configuration
|
// END: FlutterFire Configuration
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 150 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 |
|
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 |
@@ -1,4 +1,4 @@
|
|||||||
BASE_URL=https://api.velocityafrica.net/api
|
BASE_URL=https://payapi.velocityafrica.net/api
|
||||||
; BASE_URL=http://192.168.100.138:6950/api
|
; BASE_URL=http://192.168.100.138:6950/api
|
||||||
; BASE_URL=http://173.212.247.232:6950/api
|
; BASE_URL=http://173.212.247.232:6950/api
|
||||||
; BASE_URL=http://10.69.5.201:6950/api
|
; BASE_URL=http://10.69.5.201:6950/api
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
BASE_URL=https://api.velocityafrica.net/api
|
BASE_URL=https://payapi.velocityafrica.net/api
|
||||||
CLIENTID=77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com
|
CLIENTID=77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com
|
||||||
SIMULATE_PAYMENT_SUCCESS=false
|
SIMULATE_PAYMENT_SUCCESS=false
|
||||||
GATEWAY_SCRIPT_URL=https://na.gateway.mastercard.com/static/checkout/checkout.min.js
|
GATEWAY_SCRIPT_URL=https://na.gateway.mastercard.com/static/checkout/checkout.min.js
|
||||||
|
|
||||||
@@ -4,4 +4,3 @@ BASE_URL=http://localhost:6950/api
|
|||||||
CLIENTID=77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com
|
CLIENTID=77433712483-ng7pntvcpf6tnjccriuqm8dbna8vvp3b.apps.googleusercontent.com
|
||||||
SIMULATE_PAYMENT_SUCCESS=false
|
SIMULATE_PAYMENT_SUCCESS=false
|
||||||
GATEWAY_SCRIPT_URL=https://test-gateway.mastercard.com/static/checkout/checkout.min.js
|
GATEWAY_SCRIPT_URL=https://test-gateway.mastercard.com/static/checkout/checkout.min.js
|
||||||
|
|
||||||
14
lib/browser.dart
Normal file
@@ -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,14 +1,14 @@
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
|
||||||
import 'package:logger/logger.dart';
|
import 'package:logger/logger.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:qpay/config/app_config.dart';
|
||||||
|
|
||||||
var logger = Logger(printer: PrettyPrinter());
|
var logger = Logger(printer: PrettyPrinter());
|
||||||
|
|
||||||
class Http {
|
class Http {
|
||||||
final Dio dio = Dio();
|
final Dio dio = Dio();
|
||||||
|
|
||||||
final String baseUrl = dotenv.env['BASE_URL'] ?? '';
|
final String baseUrl = AppConfig.baseUrl;
|
||||||
|
|
||||||
Http() {
|
Http() {
|
||||||
dio.options.baseUrl = baseUrl;
|
dio.options.baseUrl = baseUrl;
|
||||||
@@ -45,8 +45,34 @@ class Http {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the raw [Response] object (including status code)
|
||||||
|
/// instead of just the response body.
|
||||||
|
Future<Response> getRaw(String url) async {
|
||||||
|
Map<String, String> headers = await getHeaders();
|
||||||
|
|
||||||
|
final response = await dio.get(
|
||||||
|
baseUrl + url,
|
||||||
|
options: Options(headers: headers),
|
||||||
|
);
|
||||||
|
logger.i(response.data.toString());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issues a POST request without authentication headers and returns
|
||||||
|
/// the raw [Response] object.
|
||||||
|
Future<Response> postRaw(String url, {dynamic data}) async {
|
||||||
|
Map<String, String> headers = await getHeaders();
|
||||||
|
final response = await dio.post(
|
||||||
|
baseUrl + url,
|
||||||
|
data: data,
|
||||||
|
options: Options(headers: headers),
|
||||||
|
);
|
||||||
|
logger.i(response.data.toString());
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
Future<dynamic> post(String url, dynamic data) async {
|
Future<dynamic> post(String url, dynamic data) async {
|
||||||
Map<String, String> headers = {'Content-Type': 'application/json'};
|
Map<String, String> headers = await getHeaders();
|
||||||
|
|
||||||
Response response;
|
Response response;
|
||||||
response = await dio.post(
|
response = await dio.post(
|
||||||
@@ -58,6 +84,19 @@ class Http {
|
|||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<dynamic> patch(String url, dynamic data) async {
|
||||||
|
Map<String, String> headers = await getHeaders();
|
||||||
|
|
||||||
|
Response response;
|
||||||
|
response = await dio.patch(
|
||||||
|
baseUrl + url,
|
||||||
|
data: data,
|
||||||
|
options: Options(headers: headers),
|
||||||
|
);
|
||||||
|
logger.i(response.data.toString());
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
|
|
||||||
Future<dynamic> put(String url, dynamic data) async {
|
Future<dynamic> put(String url, dynamic data) async {
|
||||||
Map<String, String> headers = await getHeaders();
|
Map<String, String> headers = await getHeaders();
|
||||||
|
|
||||||
@@ -82,4 +121,28 @@ class Http {
|
|||||||
logger.i(response.data.toString());
|
logger.i(response.data.toString());
|
||||||
return response.data;
|
return response.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<dynamic> multipartPost(
|
||||||
|
String url,
|
||||||
|
Map<String, String> fields,
|
||||||
|
String fileFieldName,
|
||||||
|
List<int> fileBytes,
|
||||||
|
String fileName,
|
||||||
|
) async {
|
||||||
|
final formData = FormData.fromMap({
|
||||||
|
...fields,
|
||||||
|
fileFieldName: MultipartFile.fromBytes(fileBytes, filename: fileName),
|
||||||
|
});
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final token = prefs.getString("token") ?? "";
|
||||||
|
|
||||||
|
final response = await dio.post(
|
||||||
|
baseUrl + url,
|
||||||
|
data: formData,
|
||||||
|
options: Options(headers: {'Authorization': 'Bearer $token'}),
|
||||||
|
);
|
||||||
|
logger.i(response.data.toString());
|
||||||
|
return response.data;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,9 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:js_interop';
|
import 'dart:js_interop';
|
||||||
|
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:qpay/config/app_config.dart';
|
||||||
import 'package:web/web.dart' as web;
|
import 'package:web/web.dart' as web;
|
||||||
|
|
||||||
const String _defaultGatewayScriptUrl =
|
|
||||||
'https://test-gateway.mastercard.com/static/checkout/checkout.min.js';
|
|
||||||
|
|
||||||
@JS('Checkout')
|
@JS('Checkout')
|
||||||
external JSAny? _checkout;
|
external JSAny? _checkout;
|
||||||
|
|
||||||
@@ -23,8 +20,7 @@ Future<void> ensureCheckoutScriptLoaded() async {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final scriptUrl =
|
final scriptUrl = AppConfig.gatewayScriptUrl;
|
||||||
dotenv.env['GATEWAY_SCRIPT_URL'] ?? _defaultGatewayScriptUrl;
|
|
||||||
|
|
||||||
final scriptElement = web.HTMLScriptElement()
|
final scriptElement = web.HTMLScriptElement()
|
||||||
..id = 'mastercard-checkout-script'
|
..id = 'mastercard-checkout-script'
|
||||||
|
|||||||
@@ -1,36 +1,26 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:qpay/auth_state.dart';
|
import 'package:qpay/auth_state.dart';
|
||||||
import 'package:qpay/routes.dart';
|
import 'package:qpay/routes.dart';
|
||||||
|
import 'package:qpay/url_strategy.dart';
|
||||||
import 'package:qpay/screens/transaction_controller.dart';
|
import 'package:qpay/screens/transaction_controller.dart';
|
||||||
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
|
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
|
||||||
|
import 'package:qpay/screens/splash/splash_provider.dart';
|
||||||
|
import 'package:qpay/screens/workspaces/workspace_provider.dart';
|
||||||
|
import 'package:qpay/providers/uptime_provider.dart';
|
||||||
|
import 'package:qpay/screens/accounts/account_provider.dart';
|
||||||
import 'package:qpay/theme/app_theme.dart';
|
import 'package:qpay/theme/app_theme.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
|
|
||||||
const String testEnv = 'assets/.env.test';
|
|
||||||
const String liveEnv = 'assets/.env.live';
|
|
||||||
const String appEnv = String.fromEnvironment('APP_ENV', defaultValue: 'test');
|
|
||||||
|
|
||||||
String resolveEnvFile(String env) {
|
|
||||||
switch (env.toLowerCase()) {
|
|
||||||
case 'live':
|
|
||||||
return liveEnv;
|
|
||||||
case 'test':
|
|
||||||
default:
|
|
||||||
return testEnv;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
|
if (kIsWeb) {
|
||||||
usePathUrlStrategy();
|
usePathUrlStrategy();
|
||||||
|
}
|
||||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||||
|
|
||||||
await dotenv.load(fileName: resolveEnvFile(appEnv));
|
|
||||||
|
|
||||||
// Read the persisted auth token before the first frame so the router
|
// Read the persisted auth token before the first frame so the router
|
||||||
// knows whether the user is already signed in on cold start. Without
|
// knows whether the user is already signed in on cold start. Without
|
||||||
// this we would briefly treat logged-in users as guests and bounce
|
// this we would briefly treat logged-in users as guests and bounce
|
||||||
@@ -46,6 +36,14 @@ void main() async {
|
|||||||
ChangeNotifierProvider<OnboardingController>(
|
ChangeNotifierProvider<OnboardingController>(
|
||||||
create: (_) => OnboardingController(),
|
create: (_) => OnboardingController(),
|
||||||
),
|
),
|
||||||
|
ChangeNotifierProvider<SplashProvider>(create: (_) => SplashProvider()),
|
||||||
|
ChangeNotifierProvider<WorkspaceProvider>(
|
||||||
|
create: (_) => WorkspaceProvider(),
|
||||||
|
),
|
||||||
|
ChangeNotifierProvider<UptimeProvider>(create: (_) => UptimeProvider()),
|
||||||
|
ChangeNotifierProvider<AccountProvider>(
|
||||||
|
create: (_) => AccountProvider(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
child: const MyApp(),
|
child: const MyApp(),
|
||||||
),
|
),
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,6 @@ class ScaffoldWithNavBar extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
||||||
bool _isLoggedIn = false;
|
|
||||||
late String initials;
|
late String initials;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -29,7 +28,6 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
|||||||
|
|
||||||
if (prefs.getString("token") != null) {
|
if (prefs.getString("token") != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoggedIn = true;
|
|
||||||
initials = prefs.getString("initials")!;
|
initials = prefs.getString("initials")!;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -51,15 +49,19 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: NavigationRail(
|
child: NavigationRail(
|
||||||
extended: true,
|
extended: true,
|
||||||
leading: Padding(
|
leading: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
vertical: 12.0,
|
vertical: 12.0,
|
||||||
|
horizontal: 8,
|
||||||
),
|
),
|
||||||
child: Image(
|
child: Image(
|
||||||
image: AssetImage('assets/velocity.png'),
|
image: AssetImage('assets/velocity.png'),
|
||||||
width: 150,
|
width: 150,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
labelType: NavigationRailLabelType.none,
|
labelType: NavigationRailLabelType.none,
|
||||||
indicatorColor: Theme.of(
|
indicatorColor: Theme.of(
|
||||||
context,
|
context,
|
||||||
@@ -153,6 +155,11 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
|||||||
"selectedIcon": Icon(Icons.history),
|
"selectedIcon": Icon(Icons.history),
|
||||||
"label": 'History',
|
"label": 'History',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"icon": Icon(Icons.more_horiz),
|
||||||
|
"selectedIcon": Icon(Icons.more_horiz),
|
||||||
|
"label": 'More',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +174,9 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
|||||||
case 2:
|
case 2:
|
||||||
context.go('/history');
|
context.go('/history');
|
||||||
break;
|
break;
|
||||||
|
case 3:
|
||||||
|
context.go('/more');
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,6 +184,10 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
|||||||
final String location = GoRouterState.of(context).uri.path;
|
final String location = GoRouterState.of(context).uri.path;
|
||||||
if (location.startsWith('/groups')) return 1;
|
if (location.startsWith('/groups')) return 1;
|
||||||
if (location.startsWith('/history')) return 2;
|
if (location.startsWith('/history')) return 2;
|
||||||
|
if (location.startsWith('/more') ||
|
||||||
|
location.startsWith('/users') ||
|
||||||
|
location.startsWith('/uptime'))
|
||||||
|
return 3;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,12 +6,13 @@ import 'package:qpay/screens/confirm/confirm_screen.dart';
|
|||||||
import 'package:qpay/screens/gateway/gateway_redirect.dart';
|
import 'package:qpay/screens/gateway/gateway_redirect.dart';
|
||||||
import 'package:qpay/screens/gateway/gateway_screen.dart';
|
import 'package:qpay/screens/gateway/gateway_screen.dart';
|
||||||
import 'package:qpay/screens/gateway/gateway_web_screen.dart';
|
import 'package:qpay/screens/gateway/gateway_web_screen.dart';
|
||||||
import 'package:qpay/screens/groups/batch_create_screen.dart';
|
import 'package:qpay/screens/groups/screens/batch_create_screen.dart';
|
||||||
import 'package:qpay/screens/groups/batch_detail_screen.dart';
|
import 'package:qpay/screens/groups/screens/batch_detail_screen.dart';
|
||||||
import 'package:qpay/screens/groups/batch_item_screen.dart';
|
import 'package:qpay/screens/groups/screens/batch_item_screen.dart';
|
||||||
import 'package:qpay/screens/groups/group_create_screen.dart';
|
import 'package:qpay/screens/groups/screens/create_group_from_file_screen.dart';
|
||||||
import 'package:qpay/screens/groups/group_detail_screen.dart';
|
import 'package:qpay/screens/groups/screens/group_create_screen.dart';
|
||||||
import 'package:qpay/screens/groups/groups_screen.dart';
|
import 'package:qpay/screens/groups/screens/group_detail_screen.dart';
|
||||||
|
import 'package:qpay/screens/groups/screens/groups_screen.dart';
|
||||||
import 'package:qpay/screens/groups/models/group_batch_model.dart';
|
import 'package:qpay/screens/groups/models/group_batch_model.dart';
|
||||||
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
|
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
|
||||||
import 'package:qpay/screens/history/history_screen.dart';
|
import 'package:qpay/screens/history/history_screen.dart';
|
||||||
@@ -27,7 +28,12 @@ import 'package:qpay/screens/poll/poll_screen.dart';
|
|||||||
import 'package:qpay/screens/profile_screen.dart';
|
import 'package:qpay/screens/profile_screen.dart';
|
||||||
import 'package:qpay/screens/receipt/receipt_screen.dart';
|
import 'package:qpay/screens/receipt/receipt_screen.dart';
|
||||||
import 'package:qpay/screens/recipient/recipients_screen.dart';
|
import 'package:qpay/screens/recipient/recipients_screen.dart';
|
||||||
import 'package:qpay/screens/splash_screen.dart';
|
import 'package:qpay/screens/splash/splash_screen.dart';
|
||||||
|
import 'package:qpay/screens/workspaces/workspace_screen.dart';
|
||||||
|
import 'package:qpay/screens/users/screens/user_list_screen.dart';
|
||||||
|
import 'package:qpay/screens/more/more_screen.dart';
|
||||||
|
import 'package:qpay/screens/uptime/uptime_status_screen.dart';
|
||||||
|
import 'package:qpay/screens/contact/contact_screen.dart';
|
||||||
|
|
||||||
/// Tracks whether the splash animation is complete so that
|
/// Tracks whether the splash animation is complete so that
|
||||||
/// GoRouter's redirect can show the splash first, then release
|
/// GoRouter's redirect can show the splash first, then release
|
||||||
@@ -63,7 +69,7 @@ final SplashState splashState = SplashState();
|
|||||||
/// Route prefixes that require the user to be signed in. Any URL
|
/// Route prefixes that require the user to be signed in. Any URL
|
||||||
/// whose path starts with one of these strings will be redirected
|
/// whose path starts with one of these strings will be redirected
|
||||||
/// to the sign-in screen when no auth token is present.
|
/// to the sign-in screen when no auth token is present.
|
||||||
const List<String> _authProtectedRoutePrefixes = ['/groups'];
|
const List<String> _authProtectedRoutePrefixes = ['/groups', '/users'];
|
||||||
|
|
||||||
bool _isAuthProtected(String path) {
|
bool _isAuthProtected(String path) {
|
||||||
for (final prefix in _authProtectedRoutePrefixes) {
|
for (final prefix in _authProtectedRoutePrefixes) {
|
||||||
@@ -134,9 +140,10 @@ GoRouter buildRouter() {
|
|||||||
return authState.requireAuth(path);
|
return authState.requireAuth(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Splash → original route or home.
|
// Splash → workspace selector (which may auto-redirect to home
|
||||||
|
// if only one workspace exists).
|
||||||
if (path == '/splash') {
|
if (path == '/splash') {
|
||||||
return splashState.intendedRoute ?? '/';
|
return '/workspace';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,12 +159,16 @@ GoRouter buildRouter() {
|
|||||||
ShellRoute(
|
ShellRoute(
|
||||||
builder: (context, state, child) {
|
builder: (context, state, child) {
|
||||||
final location = GoRouterState.of(context).uri.toString();
|
final location = GoRouterState.of(context).uri.toString();
|
||||||
if (location.startsWith('/onboarding/')) {
|
if (location.startsWith('/onboarding/') || location == '/workspace') {
|
||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
return ScaffoldWithNavBar(child: child);
|
return ScaffoldWithNavBar(child: child);
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
|
GoRoute(
|
||||||
|
path: '/workspace',
|
||||||
|
builder: (context, state) => const WorkspaceScreen(),
|
||||||
|
),
|
||||||
GoRoute(path: '/', builder: (context, state) => const HomeScreen()),
|
GoRoute(path: '/', builder: (context, state) => const HomeScreen()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/home',
|
path: '/home',
|
||||||
@@ -218,6 +229,22 @@ GoRouter buildRouter() {
|
|||||||
path: '/profile',
|
path: '/profile',
|
||||||
builder: (context, state) => const ProfileScreen(),
|
builder: (context, state) => const ProfileScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/more',
|
||||||
|
builder: (context, state) => const MoreScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/contact',
|
||||||
|
builder: (context, state) => const ContactScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/uptime',
|
||||||
|
builder: (context, state) => const UptimeStatusScreen(),
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/users',
|
||||||
|
builder: (context, state) => const UserListScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/groups',
|
path: '/groups',
|
||||||
builder: (context, state) => const GroupsScreen(),
|
builder: (context, state) => const GroupsScreen(),
|
||||||
@@ -226,6 +253,10 @@ GoRouter buildRouter() {
|
|||||||
path: '/groups/create',
|
path: '/groups/create',
|
||||||
builder: (context, state) => const GroupCreateScreen(),
|
builder: (context, state) => const GroupCreateScreen(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/groups/create-from-file',
|
||||||
|
builder: (context, state) => const CreateGroupFromFileScreen(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/groups/:groupId',
|
path: '/groups/:groupId',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
@@ -241,11 +272,10 @@ GoRouter buildRouter() {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/groups/:groupId/batches/:batchId',
|
path: '/groups/batches/:batchId',
|
||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final batchId = state.pathParameters['batchId']!;
|
final batchId = state.pathParameters['batchId']!;
|
||||||
final groupId = state.pathParameters['groupId']!;
|
return BatchDetailScreen(batchId: batchId);
|
||||||
return BatchDetailScreen(batchId: batchId, groupId: groupId);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
|
|||||||
@@ -17,24 +17,59 @@ class AccountProvider extends ChangeNotifier {
|
|||||||
String? _accountError;
|
String? _accountError;
|
||||||
String? _statementError;
|
String? _statementError;
|
||||||
|
|
||||||
|
/// Cached balances keyed by currency code (e.g. 'USD', 'ZWG').
|
||||||
|
final Map<String, AccountModel?> _balances = {};
|
||||||
|
|
||||||
AccountModel? get account => _account;
|
AccountModel? get account => _account;
|
||||||
StatementModel? get statement => _statement;
|
StatementModel? get statement => _statement;
|
||||||
bool get isLoadingAccount => _isLoadingAccount;
|
bool get isLoadingAccount => _isLoadingAccount;
|
||||||
bool get isLoadingStatement => _isLoadingStatement;
|
bool get isLoadingStatement => _isLoadingStatement;
|
||||||
String? get accountError => _accountError;
|
String? get accountError => _accountError;
|
||||||
String? get statementError => _statementError;
|
String? get statementError => _statementError;
|
||||||
|
bool _isDisposed = false;
|
||||||
|
|
||||||
Future<ApiResponse<AccountModel>> fetchAccount(String phoneNumber) async {
|
/// Returns a cached account balance for [currency], or null if not yet
|
||||||
|
/// fetched.
|
||||||
|
AccountModel? balanceForCurrency(String currency) {
|
||||||
|
return _balances[currency.toUpperCase()];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the account with name 'City Wallet' for [currency], or null.
|
||||||
|
AccountModel? cityWalletBalance() {
|
||||||
|
return _balances.values.firstWhere(
|
||||||
|
(a) => a?.name == 'City Wallet',
|
||||||
|
orElse: () => null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void notifyListeners() {
|
||||||
|
if (!_isDisposed) {
|
||||||
|
super.notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_isDisposed = true;
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse<AccountModel>> fetchAccount(
|
||||||
|
String? workspaceId,
|
||||||
|
String currency,
|
||||||
|
) async {
|
||||||
_isLoadingAccount = true;
|
_isLoadingAccount = true;
|
||||||
_accountError = null;
|
_accountError = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Map<String, dynamic> response = await http.get(
|
Map<String, dynamic> response = await http.get(
|
||||||
'/accounts/phone/$phoneNumber',
|
'/accounts/$workspaceId/balance/$currency',
|
||||||
);
|
);
|
||||||
|
|
||||||
_account = AccountModel.fromJson(response);
|
_account = AccountModel.fromJson(response);
|
||||||
|
_balances[currency.toUpperCase()] = _account;
|
||||||
_isLoadingAccount = false;
|
_isLoadingAccount = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return ApiResponse.success(_account!);
|
return ApiResponse.success(_account!);
|
||||||
@@ -45,6 +80,7 @@ class AccountProvider extends ChangeNotifier {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
return ApiResponse.failure(_accountError!);
|
return ApiResponse.failure(_accountError!);
|
||||||
} catch (e, s) {
|
} catch (e, s) {
|
||||||
|
logger.e(e);
|
||||||
logger.e(s);
|
logger.e(s);
|
||||||
_isLoadingAccount = false;
|
_isLoadingAccount = false;
|
||||||
_accountError = 'An unexpected error occurred. Please try again.';
|
_accountError = 'An unexpected error occurred. Please try again.';
|
||||||
@@ -53,8 +89,20 @@ class AccountProvider extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pre-load multiple currency balances at once. Results are stored in the
|
||||||
|
/// [_balances] map so any widget can access them later.
|
||||||
|
Future<void> preloadBalances(
|
||||||
|
String? workspaceId,
|
||||||
|
List<String> currencies,
|
||||||
|
) async {
|
||||||
|
for (final currency in currencies) {
|
||||||
|
await fetchAccount(workspaceId, currency);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<ApiResponse<StatementModel>> fetchStatement({
|
Future<ApiResponse<StatementModel>> fetchStatement({
|
||||||
required String phoneNumber,
|
required String workspaceId,
|
||||||
|
required String currency,
|
||||||
required String startDate,
|
required String startDate,
|
||||||
required String endDate,
|
required String endDate,
|
||||||
}) async {
|
}) async {
|
||||||
@@ -64,7 +112,7 @@ class AccountProvider extends ChangeNotifier {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
Map<String, dynamic> response = await http.get(
|
Map<String, dynamic> response = await http.get(
|
||||||
'/accounts/phone/$phoneNumber/statement'
|
'/accounts/$workspaceId/statement/$currency'
|
||||||
'?startDate=$startDate&endDate=$endDate',
|
'?startDate=$startDate&endDate=$endDate',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,28 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import 'package:qpay/screens/accounts/account_provider.dart';
|
import 'package:qpay/screens/accounts/account_provider.dart';
|
||||||
import 'package:qpay/screens/accounts/models/account_model.dart';
|
import 'package:qpay/screens/accounts/models/account_model.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
class AccountBalanceWidget extends StatefulWidget {
|
class AccountBalanceWidget extends StatefulWidget {
|
||||||
const AccountBalanceWidget({super.key});
|
final String? selectedCurrency;
|
||||||
|
final ValueChanged<String>? onCurrencySelected;
|
||||||
|
|
||||||
|
const AccountBalanceWidget({
|
||||||
|
super.key,
|
||||||
|
this.selectedCurrency,
|
||||||
|
this.onCurrencySelected,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<AccountBalanceWidget> createState() => _AccountBalanceWidgetState();
|
State<AccountBalanceWidget> createState() => _AccountBalanceWidgetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AccountBalanceWidgetState extends State<AccountBalanceWidget>
|
class _AccountBalanceWidgetState extends State<AccountBalanceWidget> {
|
||||||
with SingleTickerProviderStateMixin {
|
bool _isLoggedIn = false;
|
||||||
late AccountProvider _accountProvider;
|
String? _workspaceId;
|
||||||
|
|
||||||
AccountModel? _usdAccount;
|
AccountModel? _usdAccount;
|
||||||
AccountModel? _zwgAccount;
|
AccountModel? _zwgAccount;
|
||||||
@@ -21,43 +30,60 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget>
|
|||||||
bool _isLoadingZwG = false;
|
bool _isLoadingZwG = false;
|
||||||
String? _usdError;
|
String? _usdError;
|
||||||
String? _zwgError;
|
String? _zwgError;
|
||||||
String? _phoneNumber;
|
bool _showAccountError = false;
|
||||||
|
|
||||||
late AnimationController _pulseController;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_pulseController = AnimationController(
|
_loadAuthStateAndBalances();
|
||||||
vsync: this,
|
|
||||||
duration: const Duration(milliseconds: 800),
|
|
||||||
);
|
|
||||||
_accountProvider = AccountProvider();
|
|
||||||
_loadBalances();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
Future<void> _loadAuthStateAndBalances() async {
|
||||||
void dispose() {
|
|
||||||
_pulseController.dispose();
|
|
||||||
_accountProvider.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadBalances() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
_phoneNumber = prefs.getString('phone');
|
|
||||||
|
|
||||||
if (_phoneNumber == null || _phoneNumber!.isEmpty) return;
|
if (prefs.getString("token") != null) {
|
||||||
|
setState(() {
|
||||||
|
_isLoggedIn = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_workspaceId = prefs.getString('workspaceId');
|
||||||
|
|
||||||
|
if (_workspaceId == null || _workspaceId!.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_isLoggedIn) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final accountProvider = context.read<AccountProvider>();
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoadingUsd = true;
|
_isLoadingUsd = true;
|
||||||
_isLoadingZwG = true;
|
_isLoadingZwG = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Try the cached balances first
|
||||||
|
final cachedUsd = accountProvider.balanceForCurrency('USD');
|
||||||
|
final cachedZwg = accountProvider.balanceForCurrency('ZWG');
|
||||||
|
|
||||||
|
if (cachedUsd != null && cachedZwg != null) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_usdAccount = cachedUsd;
|
||||||
|
_zwgAccount = cachedZwg;
|
||||||
|
_isLoadingUsd = false;
|
||||||
|
_isLoadingZwG = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch USD balance
|
// Fetch USD balance
|
||||||
final usdResult = await _accountProvider.fetchAccount(
|
final usdResult = await accountProvider.fetchAccount(_workspaceId, 'USD');
|
||||||
'USD$_phoneNumber',
|
|
||||||
);
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoadingUsd = false;
|
_isLoadingUsd = false;
|
||||||
@@ -71,9 +97,7 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch ZWG balance
|
// Fetch ZWG balance
|
||||||
final zwgResult = await _accountProvider.fetchAccount(
|
final zwgResult = await accountProvider.fetchAccount(_workspaceId, 'ZWG');
|
||||||
'ZWG$_phoneNumber',
|
|
||||||
);
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoadingZwG = false;
|
_isLoadingZwG = false;
|
||||||
@@ -86,8 +110,65 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If both fetches failed, mark as not logged in
|
||||||
|
if (mounted &&
|
||||||
|
!usdResult.isSuccess &&
|
||||||
|
!zwgResult.isSuccess &&
|
||||||
|
_isLoggedIn) {
|
||||||
|
setState(() {
|
||||||
|
_showAccountError = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refreshBalances() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
_workspaceId = prefs.getString('workspaceId');
|
||||||
|
|
||||||
|
if (_workspaceId == null || _workspaceId!.isEmpty) return;
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final accountProvider = context.read<AccountProvider>();
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoadingUsd = true;
|
||||||
|
_isLoadingZwG = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
final usdResult = await accountProvider.fetchAccount(_workspaceId, 'USD');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_pulseController.forward();
|
setState(() {
|
||||||
|
_isLoadingUsd = false;
|
||||||
|
if (usdResult.isSuccess && usdResult.data != null) {
|
||||||
|
_usdAccount = usdResult.data;
|
||||||
|
_usdError = null;
|
||||||
|
} else {
|
||||||
|
_usdError = usdResult.error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
final zwgResult = await accountProvider.fetchAccount(_workspaceId, 'ZWG');
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoadingZwG = false;
|
||||||
|
if (zwgResult.isSuccess && zwgResult.data != null) {
|
||||||
|
_zwgAccount = zwgResult.data;
|
||||||
|
_zwgError = null;
|
||||||
|
} else {
|
||||||
|
_zwgError = zwgResult.error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mounted &&
|
||||||
|
!usdResult.isSuccess &&
|
||||||
|
!zwgResult.isSuccess &&
|
||||||
|
_isLoggedIn) {
|
||||||
|
setState(() {
|
||||||
|
_showAccountError = true;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,51 +177,85 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget>
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final isDark = theme.brightness == Brightness.dark;
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
|
|
||||||
if (_phoneNumber == null || _phoneNumber!.isEmpty) {
|
if (!_isLoggedIn) {
|
||||||
|
return _buildLoginPrompt(theme, isDark);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_workspaceId == null || _workspaceId!.isEmpty) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (_showAccountError) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 16),
|
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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildSectionLabel(
|
|
||||||
theme,
|
|
||||||
isDark,
|
|
||||||
Icons.account_balance_wallet_rounded,
|
|
||||||
'Account Balances',
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildBalanceCard(
|
child: _buildCompactCard(
|
||||||
theme: theme,
|
theme: theme,
|
||||||
isDark: isDark,
|
isDark: isDark,
|
||||||
currencyCode: 'USD',
|
currencyCode: 'USD',
|
||||||
currencyName: 'US Dollar',
|
|
||||||
flagAsset: 'united-states.png',
|
flagAsset: 'united-states.png',
|
||||||
balance: _usdAccount?.balance,
|
balance: _usdAccount?.balance,
|
||||||
isLoading: _isLoadingUsd,
|
isLoading: _isLoadingUsd,
|
||||||
error: _usdError,
|
error: _usdError,
|
||||||
|
isSelected: widget.selectedCurrency == 'USD',
|
||||||
gradientColors: [
|
gradientColors: [
|
||||||
const Color(0xFF0D47A1),
|
const Color(0xFF0D47A1),
|
||||||
const Color(0xFF1976D2),
|
const Color(0xFF1976D2),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildBalanceCard(
|
child: _buildCompactCard(
|
||||||
theme: theme,
|
theme: theme,
|
||||||
isDark: isDark,
|
isDark: isDark,
|
||||||
currencyCode: 'ZWG',
|
currencyCode: 'ZWG',
|
||||||
currencyName: 'Zimbabwe Gold',
|
|
||||||
flagAsset: 'zimbabwe.png',
|
flagAsset: 'zimbabwe.png',
|
||||||
balance: _zwgAccount?.balance,
|
balance: _zwgAccount?.balance,
|
||||||
isLoading: _isLoadingZwG,
|
isLoading: _isLoadingZwG,
|
||||||
error: _zwgError,
|
error: _zwgError,
|
||||||
|
isSelected: widget.selectedCurrency == 'ZWG',
|
||||||
gradientColors: [
|
gradientColors: [
|
||||||
const Color(0xFF1B5E20),
|
const Color(0xFF1B5E20),
|
||||||
const Color(0xFF388E3C),
|
const Color(0xFF388E3C),
|
||||||
@@ -149,42 +264,36 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 6),
|
||||||
Center(
|
Center(
|
||||||
child: TextButton.icon(
|
child: TextButton.icon(
|
||||||
onPressed: _isLoadingUsd || _isLoadingZwG
|
onPressed: _isLoadingUsd || _isLoadingZwG
|
||||||
? null
|
? null
|
||||||
: () {
|
: () {
|
||||||
_pulseController.reset();
|
_refreshBalances();
|
||||||
_loadBalances();
|
|
||||||
},
|
},
|
||||||
icon: AnimatedBuilder(
|
icon: Icon(
|
||||||
animation: _pulseController,
|
|
||||||
builder: (context, child) {
|
|
||||||
return Transform.rotate(
|
|
||||||
angle: _pulseController.value * 6.2832,
|
|
||||||
child: Icon(
|
|
||||||
Icons.refresh_rounded,
|
Icons.refresh_rounded,
|
||||||
size: 16,
|
size: 14,
|
||||||
color: _isLoadingUsd || _isLoadingZwG
|
color: _isLoadingUsd || _isLoadingZwG
|
||||||
? (isDark ? Colors.white24 : Colors.grey.shade400)
|
? (isDark ? Colors.white24 : Colors.grey.shade400)
|
||||||
: theme.colorScheme.primary,
|
: theme.colorScheme.primary,
|
||||||
),
|
),
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
label: Text(
|
label: Text(
|
||||||
_isLoadingUsd || _isLoadingZwG
|
_isLoadingUsd || _isLoadingZwG ? 'Refreshing...' : 'Refresh',
|
||||||
? 'Refreshing...'
|
|
||||||
: 'Refresh Balances',
|
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: _isLoadingUsd || _isLoadingZwG
|
color: _isLoadingUsd || _isLoadingZwG
|
||||||
? (isDark ? Colors.white24 : Colors.grey.shade400)
|
? (isDark ? Colors.white24 : Colors.grey.shade400)
|
||||||
: theme.colorScheme.primary,
|
: theme.colorScheme.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
|
minimumSize: Size.zero,
|
||||||
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -192,21 +301,93 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBalanceCard({
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// Login Prompt
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
Widget _buildLoginPrompt(ThemeData theme, bool isDark) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
theme.colorScheme.primary.withValues(alpha: 0.12),
|
||||||
|
theme.colorScheme.primary.withValues(alpha: 0.04),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.colorScheme.primary.withValues(alpha: 0.15),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Register or Login for more features',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: isDark ? Colors.white : Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
context.push('/onboarding/landing');
|
||||||
|
},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: theme.colorScheme.primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'Get Started',
|
||||||
|
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCompactCard({
|
||||||
required ThemeData theme,
|
required ThemeData theme,
|
||||||
required bool isDark,
|
required bool isDark,
|
||||||
required String currencyCode,
|
required String currencyCode,
|
||||||
required String currencyName,
|
|
||||||
required String flagAsset,
|
required String flagAsset,
|
||||||
double? balance,
|
double? balance,
|
||||||
required bool isLoading,
|
required bool isLoading,
|
||||||
String? error,
|
String? error,
|
||||||
|
required bool isSelected,
|
||||||
required List<Color> gradientColors,
|
required List<Color> gradientColors,
|
||||||
}) {
|
}) {
|
||||||
return Container(
|
return GestureDetector(
|
||||||
padding: const EdgeInsets.all(16),
|
onTap: () {
|
||||||
|
widget.onCurrencySelected?.call(currencyCode);
|
||||||
|
},
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(16),
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
colors: gradientColors,
|
colors: gradientColors,
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
@@ -214,25 +395,30 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget>
|
|||||||
),
|
),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: gradientColors[0].withValues(alpha: 0.3),
|
color: gradientColors[0].withValues(
|
||||||
blurRadius: 12,
|
alpha: isSelected ? 0.5 : 0.25,
|
||||||
offset: const Offset(0, 4),
|
),
|
||||||
|
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(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
// Top row: flag + currency code
|
// Top row: flag + currency code
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 24,
|
width: 22,
|
||||||
height: 24,
|
height: 22,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
@@ -242,84 +428,73 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget>
|
|||||||
),
|
),
|
||||||
child: ClipOval(
|
child: ClipOval(
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
flagAsset,
|
'assets/$flagAsset',
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
errorBuilder: (_, __, ___) => Icon(
|
errorBuilder: (_, __, ___) => Icon(
|
||||||
Icons.monetization_on_outlined,
|
Icons.monetization_on_outlined,
|
||||||
size: 14,
|
size: 12,
|
||||||
color: Colors.white.withValues(alpha: 0.8),
|
color: Colors.white.withValues(alpha: 0.8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Expanded(
|
||||||
|
child: Text(
|
||||||
currencyCode,
|
currencyCode,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
if (isSelected)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
width: 18,
|
||||||
|
height: 18,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withValues(alpha: 0.2),
|
color: Colors.white.withValues(alpha: 0.3),
|
||||||
borderRadius: BorderRadius.circular(8),
|
shape: BoxShape.circle,
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
currencyName,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 8,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: Colors.white.withValues(alpha: 0.9),
|
|
||||||
),
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.check_rounded,
|
||||||
|
size: 12,
|
||||||
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 10),
|
||||||
// Balance amount
|
// Balance amount
|
||||||
if (isLoading)
|
if (isLoading)
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 80,
|
width: 70,
|
||||||
height: 14,
|
height: 12,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withValues(alpha: 0.3),
|
color: Colors.white.withValues(alpha: 0.3),
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
|
||||||
Container(
|
|
||||||
width: 50,
|
|
||||||
height: 10,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withValues(alpha: 0.2),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
else if (error != null)
|
else if (error != null)
|
||||||
Column(
|
Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.error_outline_rounded,
|
Icons.error_outline_rounded,
|
||||||
color: Colors.white.withValues(alpha: 0.7),
|
color: Colors.white.withValues(alpha: 0.7),
|
||||||
size: 20,
|
size: 16,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(width: 4),
|
||||||
Text(
|
Text(
|
||||||
'Unavailable',
|
'Unavailable',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 11,
|
fontSize: 10,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Colors.white.withValues(alpha: 0.7),
|
color: Colors.white.withValues(alpha: 0.7),
|
||||||
),
|
),
|
||||||
@@ -330,56 +505,17 @@ class _AccountBalanceWidgetState extends State<AccountBalanceWidget>
|
|||||||
Text(
|
Text(
|
||||||
_formatBalance(balance ?? 0.0),
|
_formatBalance(balance ?? 0.0),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 22,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
height: 1.1,
|
height: 1.1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
currencyCode == 'USD' ? 'Available Balance' : 'Available Balance',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
color: Colors.white.withValues(alpha: 0.7),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSectionLabel(
|
|
||||||
ThemeData theme,
|
|
||||||
bool isDark,
|
|
||||||
IconData icon,
|
|
||||||
String label,
|
|
||||||
) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 32,
|
|
||||||
height: 32,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: theme.colorScheme.primary.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
),
|
),
|
||||||
child: Icon(icon, size: 16, color: theme.colorScheme.primary),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: isDark ? Colors.white : Colors.black87,
|
|
||||||
letterSpacing: 0.3,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import 'package:qpay/models/responsive_policy.dart';
|
import 'package:qpay/models/responsive_policy.dart';
|
||||||
|
import 'package:qpay/screens/accounts/account_provider.dart';
|
||||||
import 'package:qpay/screens/confirm/confirm_controller.dart';
|
import 'package:qpay/screens/confirm/confirm_controller.dart';
|
||||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
@@ -370,6 +372,15 @@ class _ConfirmScreenState extends State<ConfirmScreen>
|
|||||||
children: [
|
children: [
|
||||||
_buildDetailRow(
|
_buildDetailRow(
|
||||||
icon: Icons.monetization_on_outlined,
|
icon: Icons.monetization_on_outlined,
|
||||||
|
label: "Currency",
|
||||||
|
value: controller
|
||||||
|
.transactionController
|
||||||
|
.model
|
||||||
|
.confirmationData["debitCurrency"]
|
||||||
|
.toString(),
|
||||||
|
),
|
||||||
|
_buildDetailRow(
|
||||||
|
icon: Icons.money,
|
||||||
label: "Amount",
|
label: "Amount",
|
||||||
value: controller
|
value: controller
|
||||||
.transactionController
|
.transactionController
|
||||||
@@ -591,7 +602,12 @@ class _ConfirmScreenState extends State<ConfirmScreen>
|
|||||||
onTap: () async {
|
onTap: () async {
|
||||||
_handlePayment();
|
_handlePayment();
|
||||||
},
|
},
|
||||||
child: ListTile(
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: Container(
|
leading: Container(
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -620,7 +636,10 @@ class _ConfirmScreenState extends State<ConfirmScreen>
|
|||||||
.model
|
.model
|
||||||
.selectedPaymentProcessor
|
.selectedPaymentProcessor
|
||||||
.description,
|
.description,
|
||||||
style: TextStyle(fontWeight: FontWeight.normal, fontSize: 10),
|
style: TextStyle(
|
||||||
|
fontWeight: FontWeight.normal,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
trailing: Icon(
|
trailing: Icon(
|
||||||
Icons.chevron_right,
|
Icons.chevron_right,
|
||||||
@@ -629,6 +648,68 @@ class _ConfirmScreenState extends State<ConfirmScreen>
|
|||||||
).colorScheme.secondary.withValues(alpha: 0.7),
|
).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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,7 +57,7 @@ class GatewayController extends ChangeNotifier {
|
|||||||
|
|
||||||
dynamic response = workflowResponse['body'];
|
dynamic response = workflowResponse['body'];
|
||||||
|
|
||||||
model.status = response['status'];
|
model.status = response['pollingStatus'];
|
||||||
transactionController.model.paymentStatus = response['paymentStatus'];
|
transactionController.model.paymentStatus = response['paymentStatus'];
|
||||||
|
|
||||||
finishLoading();
|
finishLoading();
|
||||||
@@ -83,19 +83,13 @@ class GatewayController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<ApiResponse<Map<String, dynamic>>> pollTransaction(String uid) async {
|
Future<ApiResponse<Map<String, dynamic>>> pollTransaction(String uid) async {
|
||||||
// poll up to 30 times (~5 minutes at 10-second intervals)
|
// poll up to 15 times (~45 seconds at 3-second intervals)
|
||||||
int maxAttempts = 15;
|
int maxAttempts = 15;
|
||||||
int attempt = 0;
|
int attempt = 0;
|
||||||
|
|
||||||
while (!model.isCancelled && attempt < maxAttempts) {
|
while (!model.isCancelled && attempt < maxAttempts) {
|
||||||
attempt++;
|
attempt++;
|
||||||
|
|
||||||
// Wait 10 seconds before each poll request
|
|
||||||
await Future.delayed(const Duration(seconds: 10));
|
|
||||||
|
|
||||||
// Check again after delay in case it was cancelled during the delay
|
|
||||||
if (model.isCancelled) break;
|
|
||||||
|
|
||||||
ApiResponse<Map<String, dynamic>> result = await poll(uid);
|
ApiResponse<Map<String, dynamic>> result = await poll(uid);
|
||||||
|
|
||||||
// Only stop on success or failure
|
// Only stop on success or failure
|
||||||
@@ -105,8 +99,15 @@ class GatewayController extends ChangeNotifier {
|
|||||||
if (status == 'SUCCESS') {
|
if (status == 'SUCCESS') {
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Future.delayed(const Duration(seconds: 3));
|
||||||
|
if (model.isCancelled) break;
|
||||||
|
|
||||||
// Otherwise (e.g. PENDING), continue polling
|
// Otherwise (e.g. PENDING), continue polling
|
||||||
} else {
|
} else {
|
||||||
|
await Future.delayed(const Duration(seconds: 3));
|
||||||
|
if (model.isCancelled) break;
|
||||||
|
|
||||||
// Network error — show failure UI and stop
|
// Network error — show failure UI and stop
|
||||||
model.isCancelled = true;
|
model.isCancelled = true;
|
||||||
model.status = 'FAILED';
|
model.status = 'FAILED';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:qpay/config/app_config.dart';
|
||||||
import 'package:qpay/screens/gateway/gateway_controller.dart';
|
import 'package:qpay/screens/gateway/gateway_controller.dart';
|
||||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
@@ -56,7 +56,7 @@ class _GatewayScreenState extends State<GatewayScreen> {
|
|||||||
)
|
)
|
||||||
..loadRequest(Uri.parse(url));
|
..loadRequest(Uri.parse(url));
|
||||||
|
|
||||||
bool simulate = bool.parse(dotenv.env['SIMULATE_PAYMENT_SUCCESS']!);
|
bool simulate = AppConfig.simulatePaymentSuccess;
|
||||||
if (simulate) {
|
if (simulate) {
|
||||||
await Future.delayed(Duration(seconds: 10));
|
await Future.delayed(Duration(seconds: 10));
|
||||||
String targetUrl = url.replaceAll("/pay/", "/receipt/");
|
String targetUrl = url.replaceAll("/pay/", "/receipt/");
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:qpay/config/app_config.dart';
|
||||||
import 'package:qpay/models/responsive_policy.dart';
|
import 'package:qpay/models/responsive_policy.dart';
|
||||||
import 'package:qpay/screens/gateway/gateway_controller.dart';
|
import 'package:qpay/screens/gateway/gateway_controller.dart';
|
||||||
import 'package:web/web.dart' as web;
|
import 'package:qpay/browser.dart';
|
||||||
|
|
||||||
class GatewayWebScreen extends StatefulWidget {
|
class GatewayWebScreen extends StatefulWidget {
|
||||||
const GatewayWebScreen({super.key});
|
const GatewayWebScreen({super.key});
|
||||||
@@ -34,9 +34,7 @@ class _GatewayWebScreenState extends State<GatewayWebScreen> {
|
|||||||
redirecting = true;
|
redirecting = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
bool simulate = bool.parse(
|
bool simulate = AppConfig.simulatePaymentSuccess;
|
||||||
dotenv.env['SIMULATE_PAYMENT_SUCCESS'] ?? 'false',
|
|
||||||
);
|
|
||||||
if (simulate) {
|
if (simulate) {
|
||||||
// In simulation mode, just go to polling
|
// In simulation mode, just go to polling
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -50,7 +48,7 @@ class _GatewayWebScreenState extends State<GatewayWebScreen> {
|
|||||||
final transactionId =
|
final transactionId =
|
||||||
controller.transactionController.model.confirmationData['id'];
|
controller.transactionController.model.confirmationData['id'];
|
||||||
if (transactionId != null) {
|
if (transactionId != null) {
|
||||||
web.window.localStorage.setItem(
|
Browser.localStorage.setItem(
|
||||||
'pendingTransactionId',
|
'pendingTransactionId',
|
||||||
transactionId.toString(),
|
transactionId.toString(),
|
||||||
);
|
);
|
||||||
@@ -60,7 +58,7 @@ class _GatewayWebScreenState extends State<GatewayWebScreen> {
|
|||||||
// After payment, the gateway will redirect back to this app's host.
|
// After payment, the gateway will redirect back to this app's host.
|
||||||
// index.html then reads the saved transaction ID and navigates to
|
// index.html then reads the saved transaction ID and navigates to
|
||||||
// /poll/:id so the app can immediately start polling for the result.
|
// /poll/:id so the app can immediately start polling for the result.
|
||||||
web.window.location.href = url;
|
Browser.location.href = url;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,439 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
|
||||||
import 'package:qpay/screens/groups/models/group_batch_model.dart';
|
|
||||||
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
|
|
||||||
import 'package:qpay/models/responsive_policy.dart';
|
|
||||||
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
|
|
||||||
import 'package:qpay/screens/home/home_controller.dart';
|
|
||||||
import 'package:qpay/screens/pay/pay_controller.dart';
|
|
||||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
|
||||||
|
|
||||||
class BatchCreateScreen extends StatefulWidget {
|
|
||||||
final RecipientGroup group;
|
|
||||||
|
|
||||||
const BatchCreateScreen({super.key, required this.group});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<BatchCreateScreen> createState() => _BatchCreateScreenState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
|
||||||
late BatchController controller;
|
|
||||||
final _formKey = GlobalKey<FormState>();
|
|
||||||
final _descriptionController = TextEditingController();
|
|
||||||
final _amountController = TextEditingController();
|
|
||||||
final _debitPhoneController = TextEditingController();
|
|
||||||
String _currency = 'USD';
|
|
||||||
bool _loadingDropdowns = true;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
controller = BatchController(context);
|
|
||||||
_init();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _init() async {
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
if (prefs.containsKey('phone')) {
|
|
||||||
_debitPhoneController.text = prefs.getString('phone')!;
|
|
||||||
}
|
|
||||||
await Future.wait([controller.loadProviders()]);
|
|
||||||
if (mounted) setState(() => _loadingDropdowns = false);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
controller.dispose();
|
|
||||||
_descriptionController.dispose();
|
|
||||||
_amountController.dispose();
|
|
||||||
_debitPhoneController.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _submit() async {
|
|
||||||
if (!_formKey.currentState!.validate()) return;
|
|
||||||
|
|
||||||
final selectedProvider = controller.model.selectedProvider;
|
|
||||||
|
|
||||||
if (selectedProvider == null) {
|
|
||||||
AppSnackBar.show(context, 'Please select a bill provider.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final amount = double.tryParse(_amountController.text.trim());
|
|
||||||
if (amount == null || amount <= 0) {
|
|
||||||
AppSnackBar.show(context, 'Please enter a valid amount.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
|
||||||
final userId = prefs.getString('userId') ?? '';
|
|
||||||
|
|
||||||
final transactionTemplate = <String, dynamic>{
|
|
||||||
'type': 'CONFIRM',
|
|
||||||
'billClientId': selectedProvider.clientId,
|
|
||||||
'debitPhone': _debitPhoneController.text.trim(),
|
|
||||||
'debitRef': 'Velocity',
|
|
||||||
'debitCurrency': _currency,
|
|
||||||
'billName': selectedProvider.name,
|
|
||||||
'providerImage': selectedProvider.image,
|
|
||||||
'providerLabel': selectedProvider.label,
|
|
||||||
'creditName': '',
|
|
||||||
'creditEmail': '',
|
|
||||||
'productUid': '',
|
|
||||||
'billProductName': '',
|
|
||||||
'region': 'ZW',
|
|
||||||
};
|
|
||||||
|
|
||||||
final req = CreateBatchRequest(
|
|
||||||
recipientGroupId: widget.group.id ?? '',
|
|
||||||
userId: userId,
|
|
||||||
currency: _currency,
|
|
||||||
description: _descriptionController.text.trim(),
|
|
||||||
amount: amount,
|
|
||||||
transactionTemplate: transactionTemplate,
|
|
||||||
);
|
|
||||||
|
|
||||||
final result = await controller.createBatch(req);
|
|
||||||
if (!mounted) return;
|
|
||||||
|
|
||||||
if (result.isSuccess) {
|
|
||||||
final batch = result.data!;
|
|
||||||
context.pushReplacement('/groups/${widget.group.id}/batches/${batch.id}');
|
|
||||||
} else {
|
|
||||||
AppSnackBar.showError(context, result.error ?? 'Failed to create batch.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Scaffold(
|
|
||||||
appBar: AppBar(title: const Text('New Batch'), centerTitle: true),
|
|
||||||
body: ListenableBuilder(
|
|
||||||
listenable: controller,
|
|
||||||
builder: (context, _) {
|
|
||||||
if (_loadingDropdowns) {
|
|
||||||
return const Center(child: CircularProgressIndicator());
|
|
||||||
}
|
|
||||||
return Center(
|
|
||||||
child: LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final width = constraints.maxWidth > ResponsivePolicy.md
|
|
||||||
? ResponsivePolicy.md.toDouble()
|
|
||||||
: constraints.maxWidth;
|
|
||||||
return SizedBox(
|
|
||||||
width: width,
|
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
child: Form(
|
|
||||||
key: _formKey,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
_buildSectionLabel('Bill Provider'),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
_buildProviderGrid(constraints),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextFormField(
|
|
||||||
controller: _descriptionController,
|
|
||||||
textInputAction: TextInputAction.next,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Description (optional)',
|
|
||||||
hintText: 'e.g. Monthly salaries for January',
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
flex: 2,
|
|
||||||
child: TextFormField(
|
|
||||||
controller: _amountController,
|
|
||||||
keyboardType:
|
|
||||||
const TextInputType.numberWithOptions(
|
|
||||||
decimal: true,
|
|
||||||
),
|
|
||||||
textInputAction: TextInputAction.next,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText:
|
|
||||||
'Amount each recipient will receive',
|
|
||||||
hintText: 'Amount',
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
validator: (v) =>
|
|
||||||
(v == null || v.trim().isEmpty)
|
|
||||||
? 'Required'
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: DropdownButtonFormField<String>(
|
|
||||||
value: _currency,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Currency',
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
items: const [
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 'USD',
|
|
||||||
child: Text('USD'),
|
|
||||||
),
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: 'ZWG',
|
|
||||||
child: Text('ZWG'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
onChanged: (v) {
|
|
||||||
if (v != null)
|
|
||||||
setState(() => _currency = v);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
TextFormField(
|
|
||||||
controller: _debitPhoneController,
|
|
||||||
keyboardType: TextInputType.phone,
|
|
||||||
textInputAction: TextInputAction.done,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
labelText: 'Your Phone Number',
|
|
||||||
hintText: '263773000000',
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
validator: (v) => (v == null || v.trim().isEmpty)
|
|
||||||
? 'Phone number is required'
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: controller.model.isActing
|
|
||||||
? null
|
|
||||||
: _submit,
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: controller.model.isActing
|
|
||||||
? const SizedBox(
|
|
||||||
height: 20,
|
|
||||||
width: 20,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
strokeWidth: 2,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: const Text(
|
|
||||||
'Create Batch',
|
|
||||||
style: TextStyle(fontSize: 16),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSectionLabel(String text) {
|
|
||||||
return Text(
|
|
||||||
text,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Colors.grey.shade700,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static const Map<String, Color> _categoryColorMap = {
|
|
||||||
'ECONET': Color(0xFF1A73E8),
|
|
||||||
'NETONE': Color.fromARGB(255, 230, 127, 0),
|
|
||||||
'TELECEL': Color(0xFF7B1FA2),
|
|
||||||
'ZESA': Color.fromARGB(255, 230, 58, 0),
|
|
||||||
};
|
|
||||||
|
|
||||||
Color _resolveProviderColor(BillProvider provider) {
|
|
||||||
final exact = _categoryColorMap[provider.category.toUpperCase()];
|
|
||||||
if (exact != null) return exact;
|
|
||||||
|
|
||||||
final labelMatch = _categoryColorMap[provider.label.toUpperCase()];
|
|
||||||
if (labelMatch != null) return labelMatch;
|
|
||||||
|
|
||||||
final name = provider.name.toUpperCase();
|
|
||||||
for (final entry in _categoryColorMap.entries) {
|
|
||||||
if (name.contains(entry.key)) return entry.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
final hash = provider.clientId.hashCode;
|
|
||||||
final hue = (hash % 360).toDouble();
|
|
||||||
return HSVColor.fromAHSV(1.0, hue, 0.75, 0.85).toColor();
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildProviderGrid(BoxConstraints constraints) {
|
|
||||||
if (controller.model.providers.isEmpty) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: Colors.black26),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
'No providers available',
|
|
||||||
style: TextStyle(color: Colors.grey.shade600),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
|
||||||
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, gridConstraints) {
|
|
||||||
// gridConstraints.maxWidth reflects the actual rendered width of this widget
|
|
||||||
// 3-column grid: subtract 2 gaps (10*2 = 20), then divide by 3
|
|
||||||
final cardWidth = (gridConstraints.maxWidth - 20) / 3;
|
|
||||||
|
|
||||||
return Wrap(
|
|
||||||
spacing: 10,
|
|
||||||
runSpacing: 10,
|
|
||||||
children: controller.model.providers
|
|
||||||
.map(
|
|
||||||
(provider) => _buildProviderCard(provider, isDark, cardWidth),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildProviderCard(BillProvider provider, bool isDark, double width) {
|
|
||||||
final isSelected =
|
|
||||||
controller.model.selectedProvider?.clientId == provider.clientId;
|
|
||||||
final accent = _resolveProviderColor(provider);
|
|
||||||
|
|
||||||
return SizedBox(
|
|
||||||
width: width,
|
|
||||||
child: Material(
|
|
||||||
color: Colors.transparent,
|
|
||||||
child: InkWell(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
onTap: () => controller.selectProvider(provider),
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
padding: const EdgeInsets.all(10),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isDark ? const Color(0xFF1E1E1E) : Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(
|
|
||||||
color: isSelected
|
|
||||||
? accent
|
|
||||||
: isDark
|
|
||||||
? Colors.white.withValues(alpha: 0.1)
|
|
||||||
: Colors.black.withValues(alpha: 0.06),
|
|
||||||
width: isSelected ? 1.5 : 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
padding: const EdgeInsets.all(2),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: accent.withValues(alpha: 0.12),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: Image(
|
|
||||||
image: AssetImage("assets/${provider.image}"),
|
|
||||||
errorBuilder: (_, __, ___) => Icon(
|
|
||||||
Icons.payment_rounded,
|
|
||||||
size: 14,
|
|
||||||
color: accent,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
provider.name,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: isDark ? Colors.white : Colors.black87,
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
provider.description,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
color: isDark ? Colors.white54 : Colors.grey.shade500,
|
|
||||||
height: 1.3,
|
|
||||||
),
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
if (isSelected)
|
|
||||||
Positioned(
|
|
||||||
top: -2,
|
|
||||||
right: -2,
|
|
||||||
child: Container(
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: accent,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(
|
|
||||||
color: isDark
|
|
||||||
? const Color(0xFF1E1E1E)
|
|
||||||
: Colors.white,
|
|
||||||
width: 1.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.check_rounded,
|
|
||||||
size: 11,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,6 +7,8 @@ import 'package:qpay/screens/home/home_controller.dart';
|
|||||||
import 'package:qpay/screens/pay/pay_controller.dart';
|
import 'package:qpay/screens/pay/pay_controller.dart';
|
||||||
import 'package:qpay/screens/transactions/transaction_model.dart';
|
import 'package:qpay/screens/transactions/transaction_model.dart';
|
||||||
|
|
||||||
|
import '../models/batch_item_update_model.dart';
|
||||||
|
|
||||||
class BatchScreenModel {
|
class BatchScreenModel {
|
||||||
List<GroupBatch> batches = [];
|
List<GroupBatch> batches = [];
|
||||||
List<GroupBatchItem> batchItems = [];
|
List<GroupBatchItem> batchItems = [];
|
||||||
@@ -22,11 +24,26 @@ class BatchScreenModel {
|
|||||||
int currentPage = 0;
|
int currentPage = 0;
|
||||||
int totalPages = 0;
|
int totalPages = 0;
|
||||||
int totalElements = 0;
|
int totalElements = 0;
|
||||||
|
int pageSize = 10;
|
||||||
String searchQuery = '';
|
String searchQuery = '';
|
||||||
String? statusFilter;
|
String? statusFilter;
|
||||||
String? currencyFilter;
|
String? currencyFilter;
|
||||||
bool selectMode = false;
|
bool selectMode = false;
|
||||||
Set<String> selectedBatchIds = {};
|
Set<String> selectedBatchIds = {};
|
||||||
|
|
||||||
|
// Batch items pagination state
|
||||||
|
int batchItemsCurrentPage = 0;
|
||||||
|
int batchItemsTotalPages = 0;
|
||||||
|
int batchItemsTotalElements = 0;
|
||||||
|
int batchItemsPageSize = 10;
|
||||||
|
String batchItemsSearchQuery = '';
|
||||||
|
String? batchItemsStatusFilter;
|
||||||
|
String? batchItemsRecipientAccount;
|
||||||
|
bool batchItemsIsLoadingMore = false;
|
||||||
|
|
||||||
|
// Provider products cache: providerId -> list of products
|
||||||
|
Map<String, List<BillProduct>> providerProducts = {};
|
||||||
|
Set<String> loadingProviderProducts = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
class BatchController extends ChangeNotifier {
|
class BatchController extends ChangeNotifier {
|
||||||
@@ -62,10 +79,10 @@ class BatchController extends ChangeNotifier {
|
|||||||
return query.substring(0, query.length - 1);
|
return query.substring(0, query.length - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ApiResponse<List<BillProvider>>> loadProviders() async {
|
Future<ApiResponse<List<BillProvider>>> loadProviders(String currency) async {
|
||||||
try {
|
try {
|
||||||
final raw = await http.get(
|
final raw = await http.get(
|
||||||
'/public/providers?currency=USD&sort=priority,asc',
|
'/public/providers?currency=$currency&sort=priority,asc',
|
||||||
);
|
);
|
||||||
final response = _unwrap(raw);
|
final response = _unwrap(raw);
|
||||||
final List<dynamic> content;
|
final List<dynamic> content;
|
||||||
@@ -90,9 +107,11 @@ class BatchController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ApiResponse<List<PaymentProcessor>>> loadProcessors() async {
|
Future<ApiResponse<List<PaymentProcessor>>> loadProcessors(currency) async {
|
||||||
try {
|
try {
|
||||||
final raw = await http.get('/public/payment-processors');
|
final raw = await http.get(
|
||||||
|
'/public/payment-processors?currency=$currency',
|
||||||
|
);
|
||||||
final response = _unwrap(raw);
|
final response = _unwrap(raw);
|
||||||
final List<dynamic> content = response is List
|
final List<dynamic> content = response is List
|
||||||
? response
|
? response
|
||||||
@@ -111,10 +130,46 @@ class BatchController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse<List<BillProduct>>> loadProviderProducts(
|
||||||
|
String providerId,
|
||||||
|
) async {
|
||||||
|
if (model.loadingProviderProducts.contains(providerId)) {
|
||||||
|
// Already loading, return cached data if available
|
||||||
|
final cached = model.providerProducts[providerId];
|
||||||
|
if (cached != null) {
|
||||||
|
return ApiResponse.success(cached);
|
||||||
|
}
|
||||||
|
return ApiResponse.failure('Loading in progress');
|
||||||
|
}
|
||||||
|
|
||||||
|
model.loadingProviderProducts.add(providerId);
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final raw = await http.get('/public/providers/$providerId/products');
|
||||||
|
final response = _unwrap(raw);
|
||||||
|
final List<dynamic> list = response is List
|
||||||
|
? response
|
||||||
|
: (response['content'] ?? []);
|
||||||
|
final products = list
|
||||||
|
.map((e) => BillProduct.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
model.providerProducts[providerId] = products;
|
||||||
|
model.loadingProviderProducts.remove(providerId);
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
return ApiResponse.success(products);
|
||||||
|
} catch (e) {
|
||||||
|
logger.e(e);
|
||||||
|
model.loadingProviderProducts.remove(providerId);
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
return ApiResponse.failure('Failed to load products');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<ApiResponse<List<GroupBatch>>> listBatches(
|
Future<ApiResponse<List<GroupBatch>>> listBatches(
|
||||||
String groupId, {
|
String groupId, {
|
||||||
int page = 0,
|
int page = 0,
|
||||||
int size = 20,
|
int size = 10,
|
||||||
String? search,
|
String? search,
|
||||||
String? status,
|
String? status,
|
||||||
String? currency,
|
String? currency,
|
||||||
@@ -146,7 +201,7 @@ class BatchController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final raw = await http.get(
|
final raw = await http.get(
|
||||||
'/public/group-batches?${buildQueryParameters(params)}',
|
'/group-batches?${buildQueryParameters(params)}',
|
||||||
);
|
);
|
||||||
final response = _unwrap(raw);
|
final response = _unwrap(raw);
|
||||||
final pageableModel = PageableModel.fromJson(
|
final pageableModel = PageableModel.fromJson(
|
||||||
@@ -195,6 +250,7 @@ class BatchController extends ChangeNotifier {
|
|||||||
await listBatches(
|
await listBatches(
|
||||||
groupId,
|
groupId,
|
||||||
page: 0,
|
page: 0,
|
||||||
|
size: model.pageSize,
|
||||||
search: search,
|
search: search,
|
||||||
status: status,
|
status: status,
|
||||||
currency: currency,
|
currency: currency,
|
||||||
@@ -208,6 +264,43 @@ class BatchController extends ChangeNotifier {
|
|||||||
await listBatches(
|
await listBatches(
|
||||||
groupId,
|
groupId,
|
||||||
page: model.currentPage + 1,
|
page: model.currentPage + 1,
|
||||||
|
size: model.pageSize,
|
||||||
|
search: model.searchQuery.isNotEmpty ? model.searchQuery : null,
|
||||||
|
status: model.statusFilter,
|
||||||
|
currency: model.currencyFilter,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to a specific page of batches (0-based index).
|
||||||
|
/// Clears the current list and loads the requested page.
|
||||||
|
Future<void> goToPage(String groupId, int page) async {
|
||||||
|
if (model.isLoadingMore) return;
|
||||||
|
if (page < 0 || page >= model.totalPages) return;
|
||||||
|
if (page == model.currentPage) return;
|
||||||
|
|
||||||
|
// Clear existing batches so listBatches replaces instead of appending
|
||||||
|
model.batches = [];
|
||||||
|
model.currentPage = 0;
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
|
||||||
|
await listBatches(
|
||||||
|
groupId,
|
||||||
|
page: page,
|
||||||
|
size: model.pageSize,
|
||||||
|
search: model.searchQuery.isNotEmpty ? model.searchQuery : null,
|
||||||
|
status: model.statusFilter,
|
||||||
|
currency: model.currencyFilter,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Changes the page size for batches and reloads from page 0.
|
||||||
|
Future<void> setPageSize(String groupId, int size) async {
|
||||||
|
if (size == model.pageSize) return;
|
||||||
|
model.pageSize = size;
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
// Reload from page 0 with the new size
|
||||||
|
await searchBatches(
|
||||||
|
groupId,
|
||||||
search: model.searchQuery.isNotEmpty ? model.searchQuery : null,
|
search: model.searchQuery.isNotEmpty ? model.searchQuery : null,
|
||||||
status: model.statusFilter,
|
status: model.statusFilter,
|
||||||
currency: model.currencyFilter,
|
currency: model.currencyFilter,
|
||||||
@@ -215,50 +308,170 @@ class BatchController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<ApiResponse<List<GroupBatchItem>>> listBatchItems(
|
Future<ApiResponse<List<GroupBatchItem>>> listBatchItems(
|
||||||
String batchId,
|
String batchId, {
|
||||||
) async {
|
int page = 0,
|
||||||
|
int size = 10,
|
||||||
|
Map<String, String>? filters,
|
||||||
|
}) async {
|
||||||
|
if (page == 0) {
|
||||||
model.isLoading = true;
|
model.isLoading = true;
|
||||||
|
model.batchItems = [];
|
||||||
|
model.batchItemsCurrentPage = 0;
|
||||||
|
} else {
|
||||||
|
model.batchItemsIsLoadingMore = true;
|
||||||
|
}
|
||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
final params = <String, String>{
|
||||||
|
'groupBatchId': batchId,
|
||||||
|
'page': page.toString(),
|
||||||
|
'size': size.toString(),
|
||||||
|
'sort': 'createdAt,desc',
|
||||||
|
};
|
||||||
|
if (filters != null) {
|
||||||
|
params.addAll(filters);
|
||||||
|
}
|
||||||
|
|
||||||
final raw = await http.get(
|
final raw = await http.get(
|
||||||
'/public/group-batches/items?groupBatchId=$batchId&sort=createdAt,desc',
|
'/group-batches/items?${buildQueryParameters(params)}',
|
||||||
);
|
);
|
||||||
final response = _unwrap(raw);
|
final response = _unwrap(raw);
|
||||||
final List<dynamic> content;
|
final pageableModel = PageableModel.fromJson(
|
||||||
if (response is Map && response.containsKey('content')) {
|
|
||||||
content = PageableModel.fromJson(
|
|
||||||
response as Map<String, dynamic>,
|
response as Map<String, dynamic>,
|
||||||
).content;
|
);
|
||||||
} else if (response is List) {
|
|
||||||
content = response;
|
final newItems = pageableModel.content
|
||||||
} else {
|
|
||||||
content = [];
|
|
||||||
}
|
|
||||||
model.batchItems = content
|
|
||||||
.map((e) => GroupBatchItem.fromJson(e as Map<String, dynamic>))
|
.map((e) => GroupBatchItem.fromJson(e as Map<String, dynamic>))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
if (page == 0) {
|
||||||
|
model.batchItems = newItems;
|
||||||
|
} else {
|
||||||
|
model.batchItems.addAll(newItems);
|
||||||
|
}
|
||||||
|
model.batchItemsCurrentPage = pageableModel.number;
|
||||||
|
model.batchItemsTotalPages = pageableModel.totalPages;
|
||||||
|
model.batchItemsTotalElements = pageableModel.totalElements;
|
||||||
model.isLoading = false;
|
model.isLoading = false;
|
||||||
|
model.batchItemsIsLoadingMore = false;
|
||||||
|
|
||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
return ApiResponse.success(model.batchItems);
|
return ApiResponse.success(model.batchItems);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.e(e);
|
logger.e(e);
|
||||||
model.errorMessage = e.toString();
|
model.errorMessage = e.toString();
|
||||||
|
if (page == 0) {
|
||||||
model.batchItems = [];
|
model.batchItems = [];
|
||||||
|
}
|
||||||
model.isLoading = false;
|
model.isLoading = false;
|
||||||
|
model.batchItemsIsLoadingMore = false;
|
||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
return ApiResponse.failure('Failed to load batch items');
|
return ApiResponse.failure('Failed to load batch items');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> searchBatchItems(
|
||||||
|
String batchId, {
|
||||||
|
String? search,
|
||||||
|
String? status,
|
||||||
|
String? account,
|
||||||
|
}) async {
|
||||||
|
model.batchItemsSearchQuery = search ?? '';
|
||||||
|
model.batchItemsStatusFilter = status;
|
||||||
|
model.batchItemsRecipientAccount = account;
|
||||||
|
await listBatchItems(
|
||||||
|
batchId,
|
||||||
|
page: 0,
|
||||||
|
size: model.batchItemsPageSize,
|
||||||
|
filters: _buildBatchItemFilters(
|
||||||
|
search: search,
|
||||||
|
status: status,
|
||||||
|
account: account,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadNextBatchItemsPage(String batchId) async {
|
||||||
|
if (model.batchItemsIsLoadingMore ||
|
||||||
|
model.batchItemsCurrentPage + 1 >= model.batchItemsTotalPages) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await listBatchItems(
|
||||||
|
batchId,
|
||||||
|
page: model.batchItemsCurrentPage + 1,
|
||||||
|
size: model.batchItemsPageSize,
|
||||||
|
filters: _buildBatchItemFilters(
|
||||||
|
search: model.batchItemsSearchQuery.isNotEmpty
|
||||||
|
? model.batchItemsSearchQuery
|
||||||
|
: null,
|
||||||
|
status: model.batchItemsStatusFilter,
|
||||||
|
account: model.batchItemsRecipientAccount,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigates to a specific page of batch items (0-based index).
|
||||||
|
/// Clears the current list and loads the requested page.
|
||||||
|
Future<void> goToBatchItemsPage(String batchId, int page) async {
|
||||||
|
if (model.batchItemsIsLoadingMore) return;
|
||||||
|
if (page < 0 || page >= model.batchItemsTotalPages) return;
|
||||||
|
if (page == model.batchItemsCurrentPage) return;
|
||||||
|
|
||||||
|
// Clear existing items so listBatchItems replaces instead of appending
|
||||||
|
model.batchItems = [];
|
||||||
|
model.batchItemsCurrentPage = 0;
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
|
||||||
|
await listBatchItems(
|
||||||
|
batchId,
|
||||||
|
page: page,
|
||||||
|
size: model.batchItemsPageSize,
|
||||||
|
filters: _buildBatchItemFilters(
|
||||||
|
search: model.batchItemsSearchQuery.isNotEmpty
|
||||||
|
? model.batchItemsSearchQuery
|
||||||
|
: null,
|
||||||
|
status: model.batchItemsStatusFilter,
|
||||||
|
account: model.batchItemsRecipientAccount,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Changes the page size for batch items and reloads from page 0.
|
||||||
|
void setBatchItemsPageSize(int size) {
|
||||||
|
if (size == model.batchItemsPageSize) return;
|
||||||
|
model.batchItemsPageSize = size;
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the filters map for batch items queries from individual params.
|
||||||
|
Map<String, String>? _buildBatchItemFilters({
|
||||||
|
String? search,
|
||||||
|
String? status,
|
||||||
|
String? account,
|
||||||
|
}) {
|
||||||
|
final filters = <String, String>{};
|
||||||
|
if (search != null && search.isNotEmpty) {
|
||||||
|
filters['recipientName'] = search;
|
||||||
|
}
|
||||||
|
if (status != null && status.isNotEmpty) {
|
||||||
|
filters['status'] = status;
|
||||||
|
}
|
||||||
|
if (account != null && account.isNotEmpty) {
|
||||||
|
filters['recipientAccount'] = account;
|
||||||
|
}
|
||||||
|
return filters.isEmpty ? null : filters;
|
||||||
|
}
|
||||||
|
|
||||||
Future<ApiResponse<GroupBatch>> getBatch(
|
Future<ApiResponse<GroupBatch>> getBatch(
|
||||||
String batchId,
|
String batchId,
|
||||||
String userId,
|
String workspaceId,
|
||||||
) async {
|
) async {
|
||||||
model.isActing = true;
|
model.isActing = true;
|
||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
try {
|
try {
|
||||||
final raw = await http.get(
|
final raw = await http.get(
|
||||||
'/public/group-batches/$batchId?userId=$userId',
|
'/group-batches/$batchId?workspaceId=$workspaceId',
|
||||||
);
|
);
|
||||||
final response = _unwrap(raw);
|
final response = _unwrap(raw);
|
||||||
final batch = GroupBatch.fromJson(response as Map<String, dynamic>);
|
final batch = GroupBatch.fromJson(response as Map<String, dynamic>);
|
||||||
@@ -278,7 +491,7 @@ class BatchController extends ChangeNotifier {
|
|||||||
model.isActing = true;
|
model.isActing = true;
|
||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
try {
|
try {
|
||||||
final raw = await http.post('/public/group-batches', req.toJson());
|
final raw = await http.post('/group-batches', req.toJson());
|
||||||
final response = _unwrap(raw);
|
final response = _unwrap(raw);
|
||||||
final batch = GroupBatch.fromJson(response as Map<String, dynamic>);
|
final batch = GroupBatch.fromJson(response as Map<String, dynamic>);
|
||||||
model.currentBatch = batch;
|
model.currentBatch = batch;
|
||||||
@@ -301,7 +514,7 @@ class BatchController extends ChangeNotifier {
|
|||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
try {
|
try {
|
||||||
final raw = await http.put(
|
final raw = await http.put(
|
||||||
'/public/group-batches/${request.id}',
|
'/group-batches/${request.id}',
|
||||||
request.toJson(),
|
request.toJson(),
|
||||||
);
|
);
|
||||||
final response = _unwrap(raw);
|
final response = _unwrap(raw);
|
||||||
@@ -324,18 +537,52 @@ class BatchController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse<List<GroupBatchItem>>> updateBatchItems(
|
||||||
|
String batchId,
|
||||||
|
GroupBatchItemUpdateList updateList,
|
||||||
|
) async {
|
||||||
|
model.isActing = true;
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
try {
|
||||||
|
final raw = await http.put(
|
||||||
|
'/group-batches/$batchId/items',
|
||||||
|
updateList.toJson(),
|
||||||
|
);
|
||||||
|
final response = _unwrap(raw);
|
||||||
|
final items = (response as List<dynamic>)
|
||||||
|
.map((e) => GroupBatchItem.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList();
|
||||||
|
// Update items in the local batch items list if the current batch matches
|
||||||
|
model.batchItems = items;
|
||||||
|
model.isActing = false;
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
return ApiResponse.success(items);
|
||||||
|
} catch (e, s) {
|
||||||
|
logger.e(e);
|
||||||
|
logger.e(s);
|
||||||
|
model.isActing = false;
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
return ApiResponse.failure('Failed to update batch items');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<ApiResponse<GroupBatch>> confirmBatch(
|
Future<ApiResponse<GroupBatch>> confirmBatch(
|
||||||
String batchId,
|
String batchId,
|
||||||
String userId,
|
String workspaceId,
|
||||||
|
String authType,
|
||||||
) async {
|
) async {
|
||||||
model.isActing = true;
|
model.isActing = true;
|
||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
try {
|
try {
|
||||||
final key =
|
final key =
|
||||||
'batch-$batchId-confirm-${DateTime.now().millisecondsSinceEpoch}';
|
'batch-$batchId-confirm-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
final body = BatchActionRequest(userId: userId, idempotencyKey: key);
|
final body = BatchActionRequest(
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
authType: authType,
|
||||||
|
idempotencyKey: key,
|
||||||
|
);
|
||||||
final raw = await http.post(
|
final raw = await http.post(
|
||||||
'/public/group-batches/$batchId/confirm',
|
'/group-batches/$batchId/confirm',
|
||||||
body.toJson(),
|
body.toJson(),
|
||||||
);
|
);
|
||||||
final response = _unwrap(raw);
|
final response = _unwrap(raw);
|
||||||
@@ -354,16 +601,19 @@ class BatchController extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<ApiResponse<TransactionModel>> requestBatch(
|
Future<ApiResponse<TransactionModel>> requestBatch(
|
||||||
String batchId,
|
String batchId,
|
||||||
String userId,
|
String workspaceId,
|
||||||
) async {
|
) async {
|
||||||
model.isActing = true;
|
model.isActing = true;
|
||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
try {
|
try {
|
||||||
final key =
|
final key =
|
||||||
'batch-$batchId-request-${DateTime.now().millisecondsSinceEpoch}';
|
'batch-$batchId-request-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
final body = BatchActionRequest(userId: userId, idempotencyKey: key);
|
final body = BatchActionRequest(
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
idempotencyKey: key,
|
||||||
|
);
|
||||||
final raw = await http.post(
|
final raw = await http.post(
|
||||||
'/public/group-batches/$batchId/request',
|
'/group-batches/$batchId/request',
|
||||||
body.toJson(),
|
body.toJson(),
|
||||||
);
|
);
|
||||||
final response = _unwrap(raw);
|
final response = _unwrap(raw);
|
||||||
@@ -382,13 +632,13 @@ class BatchController extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<ApiResponse<Map<String, dynamic>>> downloadBatchReport(
|
Future<ApiResponse<Map<String, dynamic>>> downloadBatchReport(
|
||||||
String batchId,
|
String batchId,
|
||||||
String userId,
|
String workspaceId,
|
||||||
) async {
|
) async {
|
||||||
model.isActing = true;
|
model.isActing = true;
|
||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
try {
|
try {
|
||||||
final raw = await http.get(
|
final raw = await http.get(
|
||||||
'/public/group-batches/$batchId/report?userId=$userId',
|
'/group-batches/$batchId/report?workspaceId=$workspaceId',
|
||||||
);
|
);
|
||||||
final response = _unwrap(raw);
|
final response = _unwrap(raw);
|
||||||
model.isActing = false;
|
model.isActing = false;
|
||||||
@@ -405,16 +655,19 @@ class BatchController extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<ApiResponse<TransactionModel>> pollBatch(
|
Future<ApiResponse<TransactionModel>> pollBatch(
|
||||||
String batchId,
|
String batchId,
|
||||||
String userId,
|
String workspaceId,
|
||||||
) async {
|
) async {
|
||||||
model.isActing = true;
|
model.isActing = true;
|
||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
try {
|
try {
|
||||||
final key =
|
final key =
|
||||||
'batch-$batchId-poll-${DateTime.now().millisecondsSinceEpoch}';
|
'batch-$batchId-poll-${DateTime.now().millisecondsSinceEpoch}';
|
||||||
final body = BatchActionRequest(userId: userId, idempotencyKey: key);
|
final body = BatchActionRequest(
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
idempotencyKey: key,
|
||||||
|
);
|
||||||
final raw = await http.post(
|
final raw = await http.post(
|
||||||
'/public/group-batches/$batchId/poll',
|
'/group-batches/$batchId/poll',
|
||||||
body.toJson(),
|
body.toJson(),
|
||||||
);
|
);
|
||||||
final response = _unwrap(raw);
|
final response = _unwrap(raw);
|
||||||
@@ -423,7 +676,7 @@ class BatchController extends ChangeNotifier {
|
|||||||
);
|
);
|
||||||
model.isActing = false;
|
model.isActing = false;
|
||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
if (response['status'] != 'FAILED') {
|
if (batchTransaction.pollingStatus == 'SUCCESS') {
|
||||||
return ApiResponse.success(batchTransaction);
|
return ApiResponse.success(batchTransaction);
|
||||||
} else {
|
} else {
|
||||||
return ApiResponse.failure(
|
return ApiResponse.failure(
|
||||||
@@ -441,10 +694,10 @@ class BatchController extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> deleteBatch({
|
Future<void> deleteBatch({
|
||||||
required String batchId,
|
required String batchId,
|
||||||
required String userId,
|
required String workspaceId,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
await http.delete('/public/group-batches/$batchId?userId=$userId');
|
await http.delete('/group-batches/$batchId?workspaceId=$workspaceId');
|
||||||
model.batches.removeWhere((b) => b.id == batchId);
|
model.batches.removeWhere((b) => b.id == batchId);
|
||||||
model.selectedBatchIds.remove(batchId);
|
model.selectedBatchIds.remove(batchId);
|
||||||
model.currentBatch = null;
|
model.currentBatch = null;
|
||||||
@@ -458,7 +711,7 @@ class BatchController extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> deleteSelectedBatches({
|
Future<void> deleteSelectedBatches({
|
||||||
required String groupId,
|
required String groupId,
|
||||||
required String userId,
|
required String workspaceId,
|
||||||
}) async {
|
}) async {
|
||||||
final idsToDelete = List<String>.from(model.selectedBatchIds);
|
final idsToDelete = List<String>.from(model.selectedBatchIds);
|
||||||
model.selectMode = false;
|
model.selectMode = false;
|
||||||
@@ -466,7 +719,7 @@ class BatchController extends ChangeNotifier {
|
|||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
|
|
||||||
for (final id in idsToDelete) {
|
for (final id in idsToDelete) {
|
||||||
await deleteBatch(batchId: id, userId: userId);
|
await deleteBatch(batchId: id, workspaceId: workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Refresh after deletion
|
// Refresh after deletion
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:qpay/http/http.dart';
|
import 'package:qpay/http/http.dart';
|
||||||
import 'package:qpay/models/api_response.dart';
|
import 'package:qpay/models/api_response.dart';
|
||||||
import 'package:qpay/models/pageable_model.dart';
|
import 'package:qpay/models/pageable_model.dart';
|
||||||
|
import 'package:qpay/screens/groups/models/create_group_from_file_response.dart';
|
||||||
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
|
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
|
||||||
|
|
||||||
class GroupScreenModel {
|
class GroupScreenModel {
|
||||||
List<RecipientGroup> groups = [];
|
List<RecipientGroup> groups = [];
|
||||||
List<RecipientGroupMember> members = [];
|
List<RecipientGroupMember> members = [];
|
||||||
|
PageableModel? membersPage;
|
||||||
bool isLoading = false;
|
bool isLoading = false;
|
||||||
bool isLoadingMore = false;
|
bool isLoadingMore = false;
|
||||||
String? errorMessage;
|
String? errorMessage;
|
||||||
@@ -52,7 +54,7 @@ class GroupController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<ApiResponse<PageableModel>> listGroups({
|
Future<ApiResponse<PageableModel>> listGroups({
|
||||||
required String userId,
|
required String workspaceId,
|
||||||
int page = 0,
|
int page = 0,
|
||||||
int size = 20,
|
int size = 20,
|
||||||
String? name,
|
String? name,
|
||||||
@@ -68,7 +70,7 @@ class GroupController extends ChangeNotifier {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
final params = <String, String>{
|
final params = <String, String>{
|
||||||
'userId': userId,
|
'workspaceId': workspaceId,
|
||||||
'page': page.toString(),
|
'page': page.toString(),
|
||||||
'size': size.toString(),
|
'size': size.toString(),
|
||||||
};
|
};
|
||||||
@@ -77,11 +79,12 @@ class GroupController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final raw = await http.get(
|
final raw = await http.get(
|
||||||
'/public/recipient-groups?${buildQueryParameters(params)}',
|
'/recipient-groups?${buildQueryParameters(params)}',
|
||||||
);
|
);
|
||||||
final response = _unwrap(raw);
|
final response = _unwrap(raw);
|
||||||
final pageableModel =
|
final pageableModel = PageableModel.fromJson(
|
||||||
PageableModel.fromJson(response as Map<String, dynamic>);
|
response as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
|
||||||
final newGroups = pageableModel.content
|
final newGroups = pageableModel.content
|
||||||
.map((e) => RecipientGroup.fromJson(e as Map<String, dynamic>))
|
.map((e) => RecipientGroup.fromJson(e as Map<String, dynamic>))
|
||||||
@@ -113,20 +116,17 @@ class GroupController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> searchGroups({
|
Future<void> searchGroups({required String workspaceId, String? name}) async {
|
||||||
required String userId,
|
|
||||||
String? name,
|
|
||||||
}) async {
|
|
||||||
model.searchQuery = name ?? '';
|
model.searchQuery = name ?? '';
|
||||||
await listGroups(userId: userId, page: 0, name: name);
|
await listGroups(workspaceId: workspaceId, page: 0, name: name);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadNextPage({required String userId}) async {
|
Future<void> loadNextPage({required String workspaceId}) async {
|
||||||
if (model.isLoadingMore || model.currentPage + 1 >= model.totalPages) {
|
if (model.isLoadingMore || model.currentPage + 1 >= model.totalPages) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await listGroups(
|
await listGroups(
|
||||||
userId: userId,
|
workspaceId: workspaceId,
|
||||||
page: model.currentPage + 1,
|
page: model.currentPage + 1,
|
||||||
name: model.searchQuery.isNotEmpty ? model.searchQuery : null,
|
name: model.searchQuery.isNotEmpty ? model.searchQuery : null,
|
||||||
);
|
);
|
||||||
@@ -139,9 +139,10 @@ class GroupController extends ChangeNotifier {
|
|||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
try {
|
try {
|
||||||
final raw = await http.get(
|
final raw = await http.get(
|
||||||
'/public/recipient-groups/members?recipientGroupId=$groupId',
|
'/recipient-groups/members?recipientGroupId=$groupId',
|
||||||
);
|
);
|
||||||
final response = _unwrap(raw);
|
final response = _unwrap(raw);
|
||||||
|
model.membersPage = PageableModel.fromJson(response);
|
||||||
final List<dynamic> content;
|
final List<dynamic> content;
|
||||||
if (response is List) {
|
if (response is List) {
|
||||||
content = response;
|
content = response;
|
||||||
@@ -173,7 +174,7 @@ class GroupController extends ChangeNotifier {
|
|||||||
model.isLoading = true;
|
model.isLoading = true;
|
||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
try {
|
try {
|
||||||
final raw = await http.post('/public/recipient-groups', req.toJson());
|
final raw = await http.post('/recipient-groups', req.toJson());
|
||||||
final response = _unwrap(raw);
|
final response = _unwrap(raw);
|
||||||
final group = RecipientGroup.fromJson(response as Map<String, dynamic>);
|
final group = RecipientGroup.fromJson(response as Map<String, dynamic>);
|
||||||
model.groups = [group, ...model.groups];
|
model.groups = [group, ...model.groups];
|
||||||
@@ -191,11 +192,11 @@ class GroupController extends ChangeNotifier {
|
|||||||
|
|
||||||
Future<void> deleteGroup({
|
Future<void> deleteGroup({
|
||||||
required String groupId,
|
required String groupId,
|
||||||
required String userId,
|
required String workspaceId,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
await http.delete(
|
await http.delete(
|
||||||
'/public/recipient-groups/delete/$groupId?userId=$userId',
|
'/recipient-groups/delete/$groupId?workspaceId=$workspaceId',
|
||||||
);
|
);
|
||||||
model.groups.removeWhere((g) => g.id == groupId);
|
model.groups.removeWhere((g) => g.id == groupId);
|
||||||
model.selectedGroupIds.remove(groupId);
|
model.selectedGroupIds.remove(groupId);
|
||||||
@@ -227,11 +228,11 @@ class GroupController extends ChangeNotifier {
|
|||||||
Future<void> removeMember({
|
Future<void> removeMember({
|
||||||
required String groupId,
|
required String groupId,
|
||||||
required String recipientId,
|
required String recipientId,
|
||||||
required String userId,
|
required String workspaceId,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
await http.delete(
|
await http.delete(
|
||||||
'/public/recipient-groups/$groupId/members/$recipientId?userId=$userId',
|
'/recipient-groups/$groupId/members/$recipientId?workspaceId=$workspaceId',
|
||||||
);
|
);
|
||||||
model.members.removeWhere((m) => m.id == recipientId);
|
model.members.removeWhere((m) => m.id == recipientId);
|
||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
@@ -242,14 +243,62 @@ class GroupController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> deleteSelectedGroups({required String userId}) async {
|
Future<void> deleteSelectedGroups({required String workspaceId}) async {
|
||||||
final idsToDelete = List<String>.from(model.selectedGroupIds);
|
final idsToDelete = List<String>.from(model.selectedGroupIds);
|
||||||
model.selectMode = false;
|
model.selectMode = false;
|
||||||
model.selectedGroupIds.clear();
|
model.selectedGroupIds.clear();
|
||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
|
|
||||||
for (final id in idsToDelete) {
|
for (final id in idsToDelete) {
|
||||||
await deleteGroup(groupId: id, userId: userId);
|
await deleteGroup(groupId: id, workspaceId: workspaceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse<CreateGroupFromFileResponse>> createGroupFromFile({
|
||||||
|
required String groupName,
|
||||||
|
required String workspaceId,
|
||||||
|
required String userId,
|
||||||
|
String? description,
|
||||||
|
required String currency,
|
||||||
|
required List<int> fileBytes,
|
||||||
|
required String fileName,
|
||||||
|
}) async {
|
||||||
|
model.isLoading = true;
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final fields = <String, String>{
|
||||||
|
'groupName': groupName,
|
||||||
|
'workspaceId': workspaceId,
|
||||||
|
'userId': userId,
|
||||||
|
'currency': currency,
|
||||||
|
};
|
||||||
|
if (description != null && description.isNotEmpty) {
|
||||||
|
fields['description'] = description;
|
||||||
|
}
|
||||||
|
|
||||||
|
final raw = await http.multipartPost(
|
||||||
|
'/recipient-groups/create-from-file',
|
||||||
|
fields,
|
||||||
|
'file',
|
||||||
|
fileBytes,
|
||||||
|
fileName,
|
||||||
|
);
|
||||||
|
final response = _unwrap(raw);
|
||||||
|
final result = CreateGroupFromFileResponse.fromJson(
|
||||||
|
response as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
|
||||||
|
model.isLoading = false;
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
|
||||||
|
return ApiResponse.success(result);
|
||||||
|
} catch (e) {
|
||||||
|
logger.e(e);
|
||||||
|
model.errorMessage = e.toString();
|
||||||
|
model.isLoading = false;
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
|
return ApiResponse.failure('Failed to create group from file');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
46
lib/screens/groups/models/batch_item_update_model.dart
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
|
part 'batch_item_update_model.g.dart';
|
||||||
|
|
||||||
|
@JsonSerializable(explicitToJson: true)
|
||||||
|
class GroupBatchItemUpdateRequest {
|
||||||
|
final String? id;
|
||||||
|
final double? amount;
|
||||||
|
final String? recipientName;
|
||||||
|
final String? recipientPhone;
|
||||||
|
final String? billClientId;
|
||||||
|
final String? billName;
|
||||||
|
final String? billProductName;
|
||||||
|
final String? providerImage;
|
||||||
|
final String? providerLabel;
|
||||||
|
|
||||||
|
const GroupBatchItemUpdateRequest({
|
||||||
|
this.id,
|
||||||
|
this.amount,
|
||||||
|
this.recipientName,
|
||||||
|
this.recipientPhone,
|
||||||
|
this.billClientId,
|
||||||
|
this.billName,
|
||||||
|
this.billProductName,
|
||||||
|
this.providerImage,
|
||||||
|
this.providerLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory GroupBatchItemUpdateRequest.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$GroupBatchItemUpdateRequestFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$GroupBatchItemUpdateRequestToJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonSerializable(explicitToJson: true)
|
||||||
|
class GroupBatchItemUpdateList {
|
||||||
|
final List<GroupBatchItemUpdateRequest>? items;
|
||||||
|
final String? workspaceId;
|
||||||
|
|
||||||
|
const GroupBatchItemUpdateList({this.items, this.workspaceId});
|
||||||
|
|
||||||
|
factory GroupBatchItemUpdateList.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$GroupBatchItemUpdateListFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$GroupBatchItemUpdateListToJson(this);
|
||||||
|
}
|
||||||
53
lib/screens/groups/models/batch_item_update_model.g.dart
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'batch_item_update_model.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
GroupBatchItemUpdateRequest _$GroupBatchItemUpdateRequestFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => GroupBatchItemUpdateRequest(
|
||||||
|
id: json['id'] as String?,
|
||||||
|
amount: (json['amount'] as num?)?.toDouble(),
|
||||||
|
recipientName: json['recipientName'] as String?,
|
||||||
|
recipientPhone: json['recipientPhone'] as String?,
|
||||||
|
billClientId: json['billClientId'] as String?,
|
||||||
|
billName: json['billName'] as String?,
|
||||||
|
billProductName: json['billProductName'] as String?,
|
||||||
|
providerImage: json['providerImage'] as String?,
|
||||||
|
providerLabel: json['providerLabel'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$GroupBatchItemUpdateRequestToJson(
|
||||||
|
GroupBatchItemUpdateRequest instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'id': instance.id,
|
||||||
|
'amount': instance.amount,
|
||||||
|
'recipientName': instance.recipientName,
|
||||||
|
'recipientPhone': instance.recipientPhone,
|
||||||
|
'billClientId': instance.billClientId,
|
||||||
|
'billName': instance.billName,
|
||||||
|
'billProductName': instance.billProductName,
|
||||||
|
'providerImage': instance.providerImage,
|
||||||
|
'providerLabel': instance.providerLabel,
|
||||||
|
};
|
||||||
|
|
||||||
|
GroupBatchItemUpdateList _$GroupBatchItemUpdateListFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => GroupBatchItemUpdateList(
|
||||||
|
items: (json['items'] as List<dynamic>?)
|
||||||
|
?.map(
|
||||||
|
(e) => GroupBatchItemUpdateRequest.fromJson(e as Map<String, dynamic>),
|
||||||
|
)
|
||||||
|
.toList(),
|
||||||
|
workspaceId: json['workspaceId'] as String?,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$GroupBatchItemUpdateListToJson(
|
||||||
|
GroupBatchItemUpdateList instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'items': instance.items?.map((e) => e.toJson()).toList(),
|
||||||
|
'workspaceId': instance.workspaceId,
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
import 'package:qpay/screens/groups/models/group_batch_model.dart';
|
||||||
|
|
||||||
|
part 'create_group_from_file_response.g.dart';
|
||||||
|
|
||||||
|
@JsonSerializable(explicitToJson: true)
|
||||||
|
class FileUploadError {
|
||||||
|
final int lineNumber;
|
||||||
|
final String account;
|
||||||
|
final String name;
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const FileUploadError({
|
||||||
|
required this.lineNumber,
|
||||||
|
required this.account,
|
||||||
|
required this.name,
|
||||||
|
required this.message,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FileUploadError.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$FileUploadErrorFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$FileUploadErrorToJson(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonSerializable(explicitToJson: true)
|
||||||
|
class CreateGroupFromFileResponse {
|
||||||
|
final GroupBatch? batch;
|
||||||
|
final int totalRows;
|
||||||
|
final int successCount;
|
||||||
|
final int errorCount;
|
||||||
|
final List<FileUploadError> errors;
|
||||||
|
|
||||||
|
const CreateGroupFromFileResponse({
|
||||||
|
this.batch,
|
||||||
|
required this.totalRows,
|
||||||
|
required this.successCount,
|
||||||
|
required this.errorCount,
|
||||||
|
required this.errors,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory CreateGroupFromFileResponse.fromJson(Map<String, dynamic> json) =>
|
||||||
|
_$CreateGroupFromFileResponseFromJson(json);
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => _$CreateGroupFromFileResponseToJson(this);
|
||||||
|
|
||||||
|
bool get isSuccess => errors.isEmpty;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'create_group_from_file_response.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// JsonSerializableGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
FileUploadError _$FileUploadErrorFromJson(Map<String, dynamic> json) =>
|
||||||
|
FileUploadError(
|
||||||
|
lineNumber: (json['lineNumber'] as num).toInt(),
|
||||||
|
account: json['account'] as String,
|
||||||
|
name: json['name'] as String,
|
||||||
|
message: json['message'] as String,
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$FileUploadErrorToJson(FileUploadError instance) =>
|
||||||
|
<String, dynamic>{
|
||||||
|
'lineNumber': instance.lineNumber,
|
||||||
|
'account': instance.account,
|
||||||
|
'name': instance.name,
|
||||||
|
'message': instance.message,
|
||||||
|
};
|
||||||
|
|
||||||
|
CreateGroupFromFileResponse _$CreateGroupFromFileResponseFromJson(
|
||||||
|
Map<String, dynamic> json,
|
||||||
|
) => CreateGroupFromFileResponse(
|
||||||
|
batch: json['batch'] == null
|
||||||
|
? null
|
||||||
|
: GroupBatch.fromJson(json['batch'] as Map<String, dynamic>),
|
||||||
|
totalRows: (json['totalRows'] as num).toInt(),
|
||||||
|
successCount: (json['successCount'] as num).toInt(),
|
||||||
|
errorCount: (json['errorCount'] as num).toInt(),
|
||||||
|
errors: (json['errors'] as List<dynamic>)
|
||||||
|
.map((e) => FileUploadError.fromJson(e as Map<String, dynamic>))
|
||||||
|
.toList(),
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, dynamic> _$CreateGroupFromFileResponseToJson(
|
||||||
|
CreateGroupFromFileResponse instance,
|
||||||
|
) => <String, dynamic>{
|
||||||
|
'batch': instance.batch?.toJson(),
|
||||||
|
'totalRows': instance.totalRows,
|
||||||
|
'successCount': instance.successCount,
|
||||||
|
'errorCount': instance.errorCount,
|
||||||
|
'errors': instance.errors.map((e) => e.toJson()).toList(),
|
||||||
|
};
|
||||||
@@ -7,6 +7,7 @@ class GroupBatch {
|
|||||||
final String? id;
|
final String? id;
|
||||||
final String? recipientGroupId;
|
final String? recipientGroupId;
|
||||||
final String? userId;
|
final String? userId;
|
||||||
|
final String? workspaceId;
|
||||||
final String? currency;
|
final String? currency;
|
||||||
final String? description;
|
final String? description;
|
||||||
final double? value;
|
final double? value;
|
||||||
@@ -24,6 +25,7 @@ class GroupBatch {
|
|||||||
this.id,
|
this.id,
|
||||||
this.recipientGroupId,
|
this.recipientGroupId,
|
||||||
this.userId,
|
this.userId,
|
||||||
|
this.workspaceId,
|
||||||
this.currency,
|
this.currency,
|
||||||
this.description,
|
this.description,
|
||||||
this.value,
|
this.value,
|
||||||
@@ -50,22 +52,34 @@ class GroupBatchItem {
|
|||||||
final String? groupBatchId;
|
final String? groupBatchId;
|
||||||
final String? recipientName;
|
final String? recipientName;
|
||||||
final String? recipientPhone;
|
final String? recipientPhone;
|
||||||
|
final String? recipientAccount;
|
||||||
final double? amount;
|
final double? amount;
|
||||||
final String? status;
|
final String? status;
|
||||||
final String? transactionId;
|
final String? transactionId;
|
||||||
final String? errorMessage;
|
final String? errorMessage;
|
||||||
final String? createdAt;
|
final String? createdAt;
|
||||||
|
final String? billName;
|
||||||
|
final String? billProductName;
|
||||||
|
final String? providerLabel;
|
||||||
|
final String? providerImage;
|
||||||
|
final String? creditAddress;
|
||||||
|
|
||||||
const GroupBatchItem({
|
const GroupBatchItem({
|
||||||
this.id,
|
this.id,
|
||||||
this.groupBatchId,
|
this.groupBatchId,
|
||||||
this.recipientName,
|
this.recipientName,
|
||||||
this.recipientPhone,
|
this.recipientPhone,
|
||||||
|
this.recipientAccount,
|
||||||
this.amount,
|
this.amount,
|
||||||
this.status,
|
this.status,
|
||||||
this.transactionId,
|
this.transactionId,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
this.createdAt,
|
this.createdAt,
|
||||||
|
this.billName,
|
||||||
|
this.billProductName,
|
||||||
|
this.providerLabel,
|
||||||
|
this.providerImage,
|
||||||
|
this.creditAddress,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory GroupBatchItem.fromJson(Map<String, dynamic> json) =>
|
factory GroupBatchItem.fromJson(Map<String, dynamic> json) =>
|
||||||
@@ -78,24 +92,24 @@ class GroupBatchItem {
|
|||||||
class CreateBatchRequest {
|
class CreateBatchRequest {
|
||||||
final String recipientGroupId;
|
final String recipientGroupId;
|
||||||
final String userId;
|
final String userId;
|
||||||
|
final String workspaceId;
|
||||||
final String currency;
|
final String currency;
|
||||||
final String description;
|
final String description;
|
||||||
final double amount;
|
final double amount;
|
||||||
final String? paymentProcessorLabel;
|
final String? paymentProcessorLabel;
|
||||||
final String? paymentProcessorName;
|
final String? paymentProcessorName;
|
||||||
final String? paymentProcessorImage;
|
final String? paymentProcessorImage;
|
||||||
final Map<String, dynamic> transactionTemplate;
|
|
||||||
|
|
||||||
const CreateBatchRequest({
|
const CreateBatchRequest({
|
||||||
required this.recipientGroupId,
|
required this.recipientGroupId,
|
||||||
required this.userId,
|
required this.userId,
|
||||||
|
required this.workspaceId,
|
||||||
required this.currency,
|
required this.currency,
|
||||||
required this.description,
|
required this.description,
|
||||||
required this.amount,
|
required this.amount,
|
||||||
this.paymentProcessorLabel,
|
this.paymentProcessorLabel,
|
||||||
this.paymentProcessorName,
|
this.paymentProcessorName,
|
||||||
this.paymentProcessorImage,
|
this.paymentProcessorImage,
|
||||||
required this.transactionTemplate,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
factory CreateBatchRequest.fromJson(Map<String, dynamic> json) =>
|
factory CreateBatchRequest.fromJson(Map<String, dynamic> json) =>
|
||||||
@@ -106,12 +120,14 @@ class CreateBatchRequest {
|
|||||||
|
|
||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class BatchActionRequest {
|
class BatchActionRequest {
|
||||||
final String userId;
|
final String workspaceId;
|
||||||
final String idempotencyKey;
|
final String idempotencyKey;
|
||||||
|
final String? authType;
|
||||||
|
|
||||||
const BatchActionRequest({
|
const BatchActionRequest({
|
||||||
required this.userId,
|
required this.workspaceId,
|
||||||
required this.idempotencyKey,
|
required this.idempotencyKey,
|
||||||
|
this.authType,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory BatchActionRequest.fromJson(Map<String, dynamic> json) =>
|
factory BatchActionRequest.fromJson(Map<String, dynamic> json) =>
|
||||||
@@ -123,14 +139,14 @@ class BatchActionRequest {
|
|||||||
@JsonSerializable(explicitToJson: true)
|
@JsonSerializable(explicitToJson: true)
|
||||||
class GroupBatchUpdateRequest {
|
class GroupBatchUpdateRequest {
|
||||||
String? id;
|
String? id;
|
||||||
String? userId;
|
String? workspaceId;
|
||||||
String? paymentProcessorLabel;
|
String? paymentProcessorLabel;
|
||||||
String? paymentProcessorName;
|
String? paymentProcessorName;
|
||||||
String? paymentProcessorImage;
|
String? paymentProcessorImage;
|
||||||
|
|
||||||
GroupBatchUpdateRequest({
|
GroupBatchUpdateRequest({
|
||||||
this.id,
|
this.id,
|
||||||
this.userId,
|
this.workspaceId,
|
||||||
this.paymentProcessorLabel,
|
this.paymentProcessorLabel,
|
||||||
this.paymentProcessorName,
|
this.paymentProcessorName,
|
||||||
this.paymentProcessorImage,
|
this.paymentProcessorImage,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ GroupBatch _$GroupBatchFromJson(Map<String, dynamic> json) => GroupBatch(
|
|||||||
id: json['id'] as String?,
|
id: json['id'] as String?,
|
||||||
recipientGroupId: json['recipientGroupId'] as String?,
|
recipientGroupId: json['recipientGroupId'] as String?,
|
||||||
userId: json['userId'] as String?,
|
userId: json['userId'] as String?,
|
||||||
|
workspaceId: json['workspaceId'] as String?,
|
||||||
currency: json['currency'] as String?,
|
currency: json['currency'] as String?,
|
||||||
description: json['description'] as String?,
|
description: json['description'] as String?,
|
||||||
value: (json['value'] as num?)?.toDouble(),
|
value: (json['value'] as num?)?.toDouble(),
|
||||||
@@ -29,6 +30,7 @@ Map<String, dynamic> _$GroupBatchToJson(GroupBatch instance) =>
|
|||||||
'id': instance.id,
|
'id': instance.id,
|
||||||
'recipientGroupId': instance.recipientGroupId,
|
'recipientGroupId': instance.recipientGroupId,
|
||||||
'userId': instance.userId,
|
'userId': instance.userId,
|
||||||
|
'workspaceId': instance.workspaceId,
|
||||||
'currency': instance.currency,
|
'currency': instance.currency,
|
||||||
'description': instance.description,
|
'description': instance.description,
|
||||||
'value': instance.value,
|
'value': instance.value,
|
||||||
@@ -49,11 +51,17 @@ GroupBatchItem _$GroupBatchItemFromJson(Map<String, dynamic> json) =>
|
|||||||
groupBatchId: json['groupBatchId'] as String?,
|
groupBatchId: json['groupBatchId'] as String?,
|
||||||
recipientName: json['recipientName'] as String?,
|
recipientName: json['recipientName'] as String?,
|
||||||
recipientPhone: json['recipientPhone'] as String?,
|
recipientPhone: json['recipientPhone'] as String?,
|
||||||
|
recipientAccount: json['recipientAccount'] as String?,
|
||||||
amount: (json['amount'] as num?)?.toDouble(),
|
amount: (json['amount'] as num?)?.toDouble(),
|
||||||
status: json['status'] as String?,
|
status: json['status'] as String?,
|
||||||
transactionId: json['transactionId'] as String?,
|
transactionId: json['transactionId'] as String?,
|
||||||
errorMessage: json['errorMessage'] as String?,
|
errorMessage: json['errorMessage'] as String?,
|
||||||
createdAt: json['createdAt'] as String?,
|
createdAt: json['createdAt'] as String?,
|
||||||
|
billName: json['billName'] as String?,
|
||||||
|
billProductName: json['billProductName'] as String?,
|
||||||
|
providerLabel: json['providerLabel'] as String?,
|
||||||
|
providerImage: json['providerImage'] as String?,
|
||||||
|
creditAddress: json['creditAddress'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$GroupBatchItemToJson(GroupBatchItem instance) =>
|
Map<String, dynamic> _$GroupBatchItemToJson(GroupBatchItem instance) =>
|
||||||
@@ -62,56 +70,64 @@ Map<String, dynamic> _$GroupBatchItemToJson(GroupBatchItem instance) =>
|
|||||||
'groupBatchId': instance.groupBatchId,
|
'groupBatchId': instance.groupBatchId,
|
||||||
'recipientName': instance.recipientName,
|
'recipientName': instance.recipientName,
|
||||||
'recipientPhone': instance.recipientPhone,
|
'recipientPhone': instance.recipientPhone,
|
||||||
|
'recipientAccount': instance.recipientAccount,
|
||||||
'amount': instance.amount,
|
'amount': instance.amount,
|
||||||
'status': instance.status,
|
'status': instance.status,
|
||||||
'transactionId': instance.transactionId,
|
'transactionId': instance.transactionId,
|
||||||
'errorMessage': instance.errorMessage,
|
'errorMessage': instance.errorMessage,
|
||||||
'createdAt': instance.createdAt,
|
'createdAt': instance.createdAt,
|
||||||
|
'billName': instance.billName,
|
||||||
|
'billProductName': instance.billProductName,
|
||||||
|
'providerLabel': instance.providerLabel,
|
||||||
|
'providerImage': instance.providerImage,
|
||||||
|
'creditAddress': instance.creditAddress,
|
||||||
};
|
};
|
||||||
|
|
||||||
CreateBatchRequest _$CreateBatchRequestFromJson(Map<String, dynamic> json) =>
|
CreateBatchRequest _$CreateBatchRequestFromJson(Map<String, dynamic> json) =>
|
||||||
CreateBatchRequest(
|
CreateBatchRequest(
|
||||||
recipientGroupId: json['recipientGroupId'] as String,
|
recipientGroupId: json['recipientGroupId'] as String,
|
||||||
userId: json['userId'] as String,
|
userId: json['userId'] as String,
|
||||||
|
workspaceId: json['workspaceId'] as String,
|
||||||
currency: json['currency'] as String,
|
currency: json['currency'] as String,
|
||||||
description: json['description'] as String,
|
description: json['description'] as String,
|
||||||
amount: (json['amount'] as num).toDouble(),
|
amount: (json['amount'] as num).toDouble(),
|
||||||
paymentProcessorLabel: json['paymentProcessorLabel'] as String?,
|
paymentProcessorLabel: json['paymentProcessorLabel'] as String?,
|
||||||
paymentProcessorName: json['paymentProcessorName'] as String?,
|
paymentProcessorName: json['paymentProcessorName'] as String?,
|
||||||
paymentProcessorImage: json['paymentProcessorImage'] as String?,
|
paymentProcessorImage: json['paymentProcessorImage'] as String?,
|
||||||
transactionTemplate: json['transactionTemplate'] as Map<String, dynamic>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$CreateBatchRequestToJson(CreateBatchRequest instance) =>
|
Map<String, dynamic> _$CreateBatchRequestToJson(CreateBatchRequest instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
'recipientGroupId': instance.recipientGroupId,
|
'recipientGroupId': instance.recipientGroupId,
|
||||||
'userId': instance.userId,
|
'userId': instance.userId,
|
||||||
|
'workspaceId': instance.workspaceId,
|
||||||
'currency': instance.currency,
|
'currency': instance.currency,
|
||||||
'description': instance.description,
|
'description': instance.description,
|
||||||
'amount': instance.amount,
|
'amount': instance.amount,
|
||||||
'paymentProcessorLabel': instance.paymentProcessorLabel,
|
'paymentProcessorLabel': instance.paymentProcessorLabel,
|
||||||
'paymentProcessorName': instance.paymentProcessorName,
|
'paymentProcessorName': instance.paymentProcessorName,
|
||||||
'paymentProcessorImage': instance.paymentProcessorImage,
|
'paymentProcessorImage': instance.paymentProcessorImage,
|
||||||
'transactionTemplate': instance.transactionTemplate,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
BatchActionRequest _$BatchActionRequestFromJson(Map<String, dynamic> json) =>
|
BatchActionRequest _$BatchActionRequestFromJson(Map<String, dynamic> json) =>
|
||||||
BatchActionRequest(
|
BatchActionRequest(
|
||||||
userId: json['userId'] as String,
|
workspaceId: json['workspaceId'] as String,
|
||||||
idempotencyKey: json['idempotencyKey'] as String,
|
idempotencyKey: json['idempotencyKey'] as String,
|
||||||
|
authType: json['authType'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$BatchActionRequestToJson(BatchActionRequest instance) =>
|
Map<String, dynamic> _$BatchActionRequestToJson(BatchActionRequest instance) =>
|
||||||
<String, dynamic>{
|
<String, dynamic>{
|
||||||
'userId': instance.userId,
|
'workspaceId': instance.workspaceId,
|
||||||
'idempotencyKey': instance.idempotencyKey,
|
'idempotencyKey': instance.idempotencyKey,
|
||||||
|
'authType': instance.authType,
|
||||||
};
|
};
|
||||||
|
|
||||||
GroupBatchUpdateRequest _$GroupBatchUpdateRequestFromJson(
|
GroupBatchUpdateRequest _$GroupBatchUpdateRequestFromJson(
|
||||||
Map<String, dynamic> json,
|
Map<String, dynamic> json,
|
||||||
) => GroupBatchUpdateRequest(
|
) => GroupBatchUpdateRequest(
|
||||||
id: json['id'] as String?,
|
id: json['id'] as String?,
|
||||||
userId: json['userId'] as String?,
|
workspaceId: json['workspaceId'] as String?,
|
||||||
paymentProcessorLabel: json['paymentProcessorLabel'] as String?,
|
paymentProcessorLabel: json['paymentProcessorLabel'] as String?,
|
||||||
paymentProcessorName: json['paymentProcessorName'] as String?,
|
paymentProcessorName: json['paymentProcessorName'] as String?,
|
||||||
paymentProcessorImage: json['paymentProcessorImage'] as String?,
|
paymentProcessorImage: json['paymentProcessorImage'] as String?,
|
||||||
@@ -121,7 +137,7 @@ Map<String, dynamic> _$GroupBatchUpdateRequestToJson(
|
|||||||
GroupBatchUpdateRequest instance,
|
GroupBatchUpdateRequest instance,
|
||||||
) => <String, dynamic>{
|
) => <String, dynamic>{
|
||||||
'id': instance.id,
|
'id': instance.id,
|
||||||
'userId': instance.userId,
|
'workspaceId': instance.workspaceId,
|
||||||
'paymentProcessorLabel': instance.paymentProcessorLabel,
|
'paymentProcessorLabel': instance.paymentProcessorLabel,
|
||||||
'paymentProcessorName': instance.paymentProcessorName,
|
'paymentProcessorName': instance.paymentProcessorName,
|
||||||
'paymentProcessorImage': instance.paymentProcessorImage,
|
'paymentProcessorImage': instance.paymentProcessorImage,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ class RecipientGroup {
|
|||||||
final String name;
|
final String name;
|
||||||
final String? description;
|
final String? description;
|
||||||
final String? userId;
|
final String? userId;
|
||||||
|
final String? workspaceId;
|
||||||
final String? createdAt;
|
final String? createdAt;
|
||||||
|
|
||||||
const RecipientGroup({
|
const RecipientGroup({
|
||||||
@@ -16,6 +17,7 @@ class RecipientGroup {
|
|||||||
required this.name,
|
required this.name,
|
||||||
this.description,
|
this.description,
|
||||||
this.userId,
|
this.userId,
|
||||||
|
this.workspaceId,
|
||||||
this.createdAt,
|
this.createdAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -56,12 +58,14 @@ class CreateGroupRequest {
|
|||||||
final String name;
|
final String name;
|
||||||
final String? description;
|
final String? description;
|
||||||
final String userId;
|
final String userId;
|
||||||
|
final String workspaceId;
|
||||||
final List<Recipient> recipients;
|
final List<Recipient> recipients;
|
||||||
|
|
||||||
const CreateGroupRequest({
|
const CreateGroupRequest({
|
||||||
required this.name,
|
required this.name,
|
||||||
this.description,
|
this.description,
|
||||||
required this.userId,
|
required this.userId,
|
||||||
|
required this.workspaceId,
|
||||||
required this.recipients,
|
required this.recipients,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ RecipientGroup _$RecipientGroupFromJson(Map<String, dynamic> json) =>
|
|||||||
name: json['name'] as String,
|
name: json['name'] as String,
|
||||||
description: json['description'] as String?,
|
description: json['description'] as String?,
|
||||||
userId: json['userId'] as String?,
|
userId: json['userId'] as String?,
|
||||||
|
workspaceId: json['workspaceId'] as String?,
|
||||||
createdAt: json['createdAt'] as String?,
|
createdAt: json['createdAt'] as String?,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ Map<String, dynamic> _$RecipientGroupToJson(RecipientGroup instance) =>
|
|||||||
'name': instance.name,
|
'name': instance.name,
|
||||||
'description': instance.description,
|
'description': instance.description,
|
||||||
'userId': instance.userId,
|
'userId': instance.userId,
|
||||||
|
'workspaceId': instance.workspaceId,
|
||||||
'createdAt': instance.createdAt,
|
'createdAt': instance.createdAt,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -53,6 +55,7 @@ CreateGroupRequest _$CreateGroupRequestFromJson(Map<String, dynamic> json) =>
|
|||||||
name: json['name'] as String,
|
name: json['name'] as String,
|
||||||
description: json['description'] as String?,
|
description: json['description'] as String?,
|
||||||
userId: json['userId'] as String,
|
userId: json['userId'] as String,
|
||||||
|
workspaceId: json['workspaceId'] as String,
|
||||||
recipients: (json['recipients'] as List<dynamic>)
|
recipients: (json['recipients'] as List<dynamic>)
|
||||||
.map((e) => Recipient.fromJson(e as Map<String, dynamic>))
|
.map((e) => Recipient.fromJson(e as Map<String, dynamic>))
|
||||||
.toList(),
|
.toList(),
|
||||||
@@ -63,5 +66,6 @@ Map<String, dynamic> _$CreateGroupRequestToJson(CreateGroupRequest instance) =>
|
|||||||
'name': instance.name,
|
'name': instance.name,
|
||||||
'description': instance.description,
|
'description': instance.description,
|
||||||
'userId': instance.userId,
|
'userId': instance.userId,
|
||||||
|
'workspaceId': instance.workspaceId,
|
||||||
'recipients': instance.recipients.map((e) => e.toJson()).toList(),
|
'recipients': instance.recipients.map((e) => e.toJson()).toList(),
|
||||||
};
|
};
|
||||||
|
|||||||
494
lib/screens/groups/screens/batch_create_screen.dart
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:qpay/screens/groups/models/group_batch_model.dart';
|
||||||
|
import 'package:qpay/screens/groups/models/batch_item_update_model.dart';
|
||||||
|
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
|
||||||
|
import 'package:qpay/models/responsive_policy.dart';
|
||||||
|
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
|
||||||
|
import 'package:qpay/screens/provider/provider_controller.dart';
|
||||||
|
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
class BatchCreateScreen extends StatefulWidget {
|
||||||
|
final RecipientGroup group;
|
||||||
|
|
||||||
|
const BatchCreateScreen({super.key, required this.group});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BatchCreateScreen> createState() => _BatchCreateScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
||||||
|
late BatchController batchController;
|
||||||
|
late ProviderController providerController;
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _descriptionController = TextEditingController();
|
||||||
|
final _amountController = TextEditingController();
|
||||||
|
final _debitPhoneController = TextEditingController();
|
||||||
|
String _currency = 'USD';
|
||||||
|
bool _loadingDropdowns = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
batchController = BatchController(context);
|
||||||
|
providerController = ProviderController(context);
|
||||||
|
// Sync listeners so the UI rebuilds when either controller changes
|
||||||
|
batchController.addListener(_onControllerChange);
|
||||||
|
providerController.addListener(_onControllerChange);
|
||||||
|
_init();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onControllerChange() {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
// When products are available and a selected product exists with a non-zero
|
||||||
|
// defaultAmount, populate the amount field (only if it's empty or was cleared).
|
||||||
|
if (providerController.model.products.isNotEmpty &&
|
||||||
|
providerController.model.selectedProduct != null &&
|
||||||
|
_amountController.text.isEmpty) {
|
||||||
|
final product = providerController.model.selectedProduct!;
|
||||||
|
if (product.defaultAmount > 0) {
|
||||||
|
_amountController.text = product.defaultAmount.toStringAsFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _init() async {
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
if (prefs.containsKey('phone')) {
|
||||||
|
_debitPhoneController.text = prefs.getString('phone')!;
|
||||||
|
}
|
||||||
|
await Future.wait([
|
||||||
|
providerController.loadProviders(_currency),
|
||||||
|
batchController.loadProcessors(_currency),
|
||||||
|
]);
|
||||||
|
if (mounted) setState(() => _loadingDropdowns = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
batchController.removeListener(_onControllerChange);
|
||||||
|
providerController.removeListener(_onControllerChange);
|
||||||
|
batchController.dispose();
|
||||||
|
providerController.dispose();
|
||||||
|
_descriptionController.dispose();
|
||||||
|
_amountController.dispose();
|
||||||
|
_debitPhoneController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
final amount = double.tryParse(_amountController.text.trim());
|
||||||
|
if (amount == null || amount <= 0) {
|
||||||
|
AppSnackBar.show(context, 'Please enter a valid amount.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final userId = prefs.getString('userId') ?? '';
|
||||||
|
final workspaceId = prefs.getString('workspaceId') ?? '';
|
||||||
|
|
||||||
|
final req = CreateBatchRequest(
|
||||||
|
recipientGroupId: widget.group.id ?? '',
|
||||||
|
userId: userId,
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
currency: _currency,
|
||||||
|
description: _descriptionController.text.trim(),
|
||||||
|
amount: amount,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 1: Create the batch
|
||||||
|
final result = await batchController.createBatch(req);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (result.isSuccess) {
|
||||||
|
final batch = result.data!;
|
||||||
|
|
||||||
|
// Step 2: Update batch items with selected provider & product
|
||||||
|
final selectedProvider = providerController.model.selectedProvider;
|
||||||
|
final selectedProduct = providerController.model.selectedProduct;
|
||||||
|
|
||||||
|
if (selectedProvider != null && batch.id != null) {
|
||||||
|
await _updateBatchItems(
|
||||||
|
batch.id!,
|
||||||
|
selectedProvider,
|
||||||
|
selectedProduct,
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
context.pushReplacement('/groups/batches/${batch.id}');
|
||||||
|
} else {
|
||||||
|
AppSnackBar.showError(context, result.error ?? 'Failed to create batch.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _updateBatchItems(
|
||||||
|
String batchId,
|
||||||
|
BillProvider provider,
|
||||||
|
BillProduct? product,
|
||||||
|
String workspaceId,
|
||||||
|
) async {
|
||||||
|
// Fetch the batch items so we can update them
|
||||||
|
final itemsResult = await batchController.listBatchItems(batchId);
|
||||||
|
if (!mounted || !itemsResult.isSuccess) return;
|
||||||
|
|
||||||
|
if (itemsResult.data!.isEmpty) return;
|
||||||
|
|
||||||
|
final updateItems = itemsResult.data!.map((item) {
|
||||||
|
return GroupBatchItemUpdateRequest(
|
||||||
|
id: item.id,
|
||||||
|
amount: item.amount,
|
||||||
|
recipientName: item.recipientName,
|
||||||
|
recipientPhone: item.recipientPhone,
|
||||||
|
providerImage: provider.image,
|
||||||
|
providerLabel: provider.label,
|
||||||
|
billClientId: provider.clientId,
|
||||||
|
billName: provider.name,
|
||||||
|
billProductName: product?.name ?? '',
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
|
||||||
|
final updateList = GroupBatchItemUpdateList(
|
||||||
|
items: updateItems,
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
await batchController.updateBatchItems(batchId, updateList);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('New Batch'), centerTitle: true),
|
||||||
|
body: ListenableBuilder(
|
||||||
|
listenable: Listenable.merge([batchController, providerController]),
|
||||||
|
builder: (context, _) {
|
||||||
|
if (_loadingDropdowns) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.topCenter,
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final width = constraints.maxWidth > ResponsivePolicy.md
|
||||||
|
? ResponsivePolicy.md.toDouble()
|
||||||
|
: constraints.maxWidth;
|
||||||
|
return SizedBox(
|
||||||
|
width: width,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
// Row 1: Description + Currency
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 2,
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _descriptionController,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Description (optional)',
|
||||||
|
hintText:
|
||||||
|
'e.g. Monthly salaries for January',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: DropdownButtonFormField<String>(
|
||||||
|
initialValue: _currency,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Currency',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'USD',
|
||||||
|
child: Text('USD'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'ZWG',
|
||||||
|
child: Text('ZWG'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v != null) _updateCurrency(v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Row 2: Provider + Product
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(child: _buildProviderDropdown()),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(child: _buildProductDropdown()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// Row 3: Amount + Phone
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
flex: 1,
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _amountController,
|
||||||
|
readOnly: providerController
|
||||||
|
.model
|
||||||
|
.products
|
||||||
|
.isNotEmpty,
|
||||||
|
keyboardType:
|
||||||
|
const TextInputType.numberWithOptions(
|
||||||
|
decimal: true,
|
||||||
|
),
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText:
|
||||||
|
'Amount each recipient will receive',
|
||||||
|
hintText:
|
||||||
|
providerController
|
||||||
|
.model
|
||||||
|
.products
|
||||||
|
.isNotEmpty
|
||||||
|
? 'Set by product'
|
||||||
|
: 'Amount',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
validator: (v) =>
|
||||||
|
(v == null || v.trim().isEmpty)
|
||||||
|
? 'Required'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
flex: 1,
|
||||||
|
child: TextFormField(
|
||||||
|
controller: _debitPhoneController,
|
||||||
|
keyboardType: TextInputType.phone,
|
||||||
|
textInputAction: TextInputAction.done,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Your Phone Number',
|
||||||
|
hintText: '263773000000',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
validator: (v) =>
|
||||||
|
(v == null || v.trim().isEmpty)
|
||||||
|
? 'Phone number is required'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: batchController.model.isActing
|
||||||
|
? null
|
||||||
|
: _submit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: batchController.model.isActing
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text(
|
||||||
|
'Create Batch',
|
||||||
|
style: TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProviderDropdown() {
|
||||||
|
final providers = providerController.model.providers;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
if (providerController.model.isLoadingProviders) {
|
||||||
|
return const SizedBox(
|
||||||
|
height: 56,
|
||||||
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providers.isEmpty) {
|
||||||
|
return const SizedBox(
|
||||||
|
height: 56,
|
||||||
|
child: Center(child: Text('No providers available')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DropdownButtonFormField<BillProvider>(
|
||||||
|
initialValue: providerController.model.selectedProvider,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Provider',
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
items: providers.map((provider) {
|
||||||
|
return DropdownMenuItem<BillProvider>(
|
||||||
|
value: provider,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/${provider.image}',
|
||||||
|
width: 24,
|
||||||
|
height: 24,
|
||||||
|
errorBuilder: (_, __, ___) =>
|
||||||
|
const Icon(Icons.payment_rounded, size: 20),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
provider.name,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: isDark ? Colors.white : Colors.black87,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (provider) {
|
||||||
|
if (provider != null) {
|
||||||
|
_amountController.clear();
|
||||||
|
providerController.selectProvider(provider);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProductDropdown() {
|
||||||
|
final products = providerController.model.products;
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
if (providerController.model.selectedProvider == null) {
|
||||||
|
return InputDecorator(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Product',
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'Select a provider first',
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey.shade500),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (providerController.model.isLoadingProducts) {
|
||||||
|
return const SizedBox(
|
||||||
|
height: 56,
|
||||||
|
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (products.isEmpty) {
|
||||||
|
return InputDecorator(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Product',
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'No products',
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey.shade500),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DropdownButtonFormField<BillProduct>(
|
||||||
|
value: providerController.model.selectedProduct,
|
||||||
|
isExpanded: true,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Product',
|
||||||
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
items: products.map((product) {
|
||||||
|
return DropdownMenuItem<BillProduct>(
|
||||||
|
value: product,
|
||||||
|
child: Text(
|
||||||
|
'${product.displayName.isNotEmpty ? product.displayName : product.name} - \$${product.defaultAmount}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: isDark ? Colors.white : Colors.black87,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
onChanged: (product) {
|
||||||
|
if (product != null) {
|
||||||
|
providerController.selectProduct(product);
|
||||||
|
if (product.defaultAmount > 0) {
|
||||||
|
_amountController.text = product.defaultAmount.toStringAsFixed(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateCurrency(String v) {
|
||||||
|
setState(() => _currency = v);
|
||||||
|
providerController.loadProviders(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
3433
lib/screens/groups/screens/batch_detail_screen.dart
Normal file
479
lib/screens/groups/screens/create_group_from_file_screen.dart
Normal file
@@ -0,0 +1,479 @@
|
|||||||
|
import 'dart:io' show File, Platform;
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:qpay/screens/groups/controllers/group_controller.dart';
|
||||||
|
import 'package:qpay/screens/groups/models/create_group_from_file_response.dart';
|
||||||
|
import 'package:qpay/models/responsive_policy.dart';
|
||||||
|
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
class CreateGroupFromFileScreen extends StatefulWidget {
|
||||||
|
const CreateGroupFromFileScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CreateGroupFromFileScreen> createState() =>
|
||||||
|
_CreateGroupFromFileScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CreateGroupFromFileScreenState extends State<CreateGroupFromFileScreen> {
|
||||||
|
late GroupController controller;
|
||||||
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final _groupNameController = TextEditingController();
|
||||||
|
final _descriptionController = TextEditingController();
|
||||||
|
String _currency = 'USD';
|
||||||
|
List<int>? _fileBytes;
|
||||||
|
String? _selectedFileName;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
controller = GroupController(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_groupNameController.dispose();
|
||||||
|
_descriptionController.dispose();
|
||||||
|
controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _downloadTemplate() async {
|
||||||
|
try {
|
||||||
|
final bytes = await rootBundle.load('assets/group-batch-example.csv');
|
||||||
|
final data = bytes.buffer.asUint8List();
|
||||||
|
|
||||||
|
if (kIsWeb) {
|
||||||
|
// Web: use url_launcher with a data URI
|
||||||
|
final uri = Uri.dataFromString(
|
||||||
|
String.fromCharCodes(data),
|
||||||
|
mimeType: 'text/csv',
|
||||||
|
encoding: null,
|
||||||
|
);
|
||||||
|
await launchUrl(uri, mode: LaunchMode.platformDefault);
|
||||||
|
} else {
|
||||||
|
// Mobile/desktop: write to temp file and open with url_launcher
|
||||||
|
final dir = Platform.isAndroid
|
||||||
|
? await getTemporaryDirectory()
|
||||||
|
: await getApplicationDocumentsDirectory();
|
||||||
|
final file = File('${dir.path}/group-batch-example.csv');
|
||||||
|
await file.writeAsBytes(data);
|
||||||
|
final fileUri = Uri.file(file.path);
|
||||||
|
if (await canLaunchUrl(fileUri)) {
|
||||||
|
await launchUrl(fileUri, mode: LaunchMode.externalApplication);
|
||||||
|
} else {
|
||||||
|
throw Exception('Cannot open file');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
AppSnackBar.showError(context, 'Failed to download template: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pickFile() async {
|
||||||
|
final result = await FilePicker.pickFiles(
|
||||||
|
type: FileType.custom,
|
||||||
|
allowedExtensions: ['csv'],
|
||||||
|
withData: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result != null && result.files.isNotEmpty) {
|
||||||
|
final file = result.files.first;
|
||||||
|
if (file.bytes != null) {
|
||||||
|
setState(() {
|
||||||
|
_fileBytes = file.bytes;
|
||||||
|
_selectedFileName = file.name;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
if (!_formKey.currentState!.validate()) return;
|
||||||
|
|
||||||
|
if (_fileBytes == null || _selectedFileName == null) {
|
||||||
|
AppSnackBar.show(context, 'Please select a CSV file to upload.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final prefs = await SharedPreferences.getInstance();
|
||||||
|
final workspaceId = prefs.getString('workspaceId') ?? '';
|
||||||
|
final userId = prefs.getString('userId') ?? '';
|
||||||
|
|
||||||
|
final result = await controller.createGroupFromFile(
|
||||||
|
groupName: _groupNameController.text.trim(),
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
userId: userId,
|
||||||
|
description: _descriptionController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: _descriptionController.text.trim(),
|
||||||
|
currency: _currency,
|
||||||
|
fileBytes: _fileBytes!,
|
||||||
|
fileName: _selectedFileName!,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (result.isSuccess) {
|
||||||
|
final response = result.data!;
|
||||||
|
if (response.isSuccess && response.batch != null) {
|
||||||
|
final batch = response.batch!;
|
||||||
|
final groupId = batch.recipientGroupId ?? '';
|
||||||
|
|
||||||
|
AppSnackBar.showSuccess(
|
||||||
|
context,
|
||||||
|
'Group & batch created successfully. '
|
||||||
|
'${response.successCount} of ${response.totalRows} rows processed.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (groupId.isNotEmpty && batch.id != null) {
|
||||||
|
context.pushReplacement('/groups/batches/${batch.id}');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Partial failure — show errors
|
||||||
|
_showErrorsBottomSheet(response);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AppSnackBar.showError(
|
||||||
|
context,
|
||||||
|
result.error ?? 'Failed to create group from file.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showErrorsBottomSheet(CreateGroupFromFileResponse response) {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (sheetContext) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.warning_amber_rounded,
|
||||||
|
color: Colors.orange.shade700,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
'Upload completed with errors',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: isDark ? Colors.white : Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'${response.errorCount} of ${response.totalRows} rows failed. '
|
||||||
|
'${response.successCount} rows succeeded.',
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (response.errors.isNotEmpty) ...[
|
||||||
|
Flexible(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: MediaQuery.of(sheetContext).size.height * 0.4,
|
||||||
|
),
|
||||||
|
child: ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: response.errors.length,
|
||||||
|
separatorBuilder: (_, __) => const Divider(height: 1),
|
||||||
|
itemBuilder: (_, index) {
|
||||||
|
final error = response.errors[index];
|
||||||
|
return ListTile(
|
||||||
|
dense: true,
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
leading: CircleAvatar(
|
||||||
|
radius: 14,
|
||||||
|
backgroundColor: Colors.red.shade50,
|
||||||
|
child: Text(
|
||||||
|
'${error.lineNumber}',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.red.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
error.name.isNotEmpty
|
||||||
|
? '${error.name} (${error.account})'
|
||||||
|
: error.account,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
error.message,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () => Navigator.of(sheetContext).pop(),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const Text('Dismiss'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Create from File'), centerTitle: true),
|
||||||
|
body: ListenableBuilder(
|
||||||
|
listenable: controller,
|
||||||
|
builder: (context, _) {
|
||||||
|
return Center(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final width = constraints.maxWidth > ResponsivePolicy.md
|
||||||
|
? ResponsivePolicy.md.toDouble()
|
||||||
|
: constraints.maxWidth;
|
||||||
|
return SizedBox(
|
||||||
|
width: width,
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Form(
|
||||||
|
key: _formKey,
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextFormField(
|
||||||
|
controller: _groupNameController,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Group Name',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
validator: (v) => (v == null || v.trim().isEmpty)
|
||||||
|
? 'Group name is required'
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: _descriptionController,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Description (optional)',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<String>(
|
||||||
|
initialValue: _currency,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Currency',
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'USD',
|
||||||
|
child: Text('USD'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'ZWG',
|
||||||
|
child: Text('ZWG'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
onChanged: (v) {
|
||||||
|
if (v != null) setState(() => _currency = v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
Text(
|
||||||
|
'Recipients File',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.grey.shade800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Upload a CSV file with columns: name, account, amount, billProductName',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
InkWell(
|
||||||
|
onTap: _pickFile,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: _fileBytes != null
|
||||||
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: Colors.grey.shade300,
|
||||||
|
width: 1.5,
|
||||||
|
style: BorderStyle.solid,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
color: _fileBytes != null
|
||||||
|
? Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary.withAlpha(12)
|
||||||
|
: Colors.grey.shade50,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
_fileBytes != null
|
||||||
|
? Icons.insert_drive_file
|
||||||
|
: Icons.cloud_upload_outlined,
|
||||||
|
size: 40,
|
||||||
|
color: _fileBytes != null
|
||||||
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_selectedFileName ??
|
||||||
|
'Tap to select CSV file',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: _fileBytes != null
|
||||||
|
? Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary
|
||||||
|
: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
if (_fileBytes == null) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
'Accepted format: .csv',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: _downloadTemplate,
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.download_rounded,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
label: const Text('Download Template'),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: controller.model.isLoading
|
||||||
|
? null
|
||||||
|
: _submit,
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: controller.model.isLoading
|
||||||
|
? const SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text(
|
||||||
|
'Upload & Create',
|
||||||
|
style: TextStyle(fontSize: 16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import 'package:qpay/screens/groups/controllers/group_controller.dart';
|
|||||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../recipient/recipients_controller.dart';
|
import '../../recipient/recipients_controller.dart';
|
||||||
|
|
||||||
class GroupCreateScreen extends StatefulWidget {
|
class GroupCreateScreen extends StatefulWidget {
|
||||||
const GroupCreateScreen({super.key});
|
const GroupCreateScreen({super.key});
|
||||||
@@ -49,12 +49,16 @@ class _GroupCreateScreenState extends State<GroupCreateScreen> {
|
|||||||
if (parts.length >= 2) {
|
if (parts.length >= 2) {
|
||||||
final name = parts[0].trim();
|
final name = parts[0].trim();
|
||||||
final account = parts[1].trim();
|
final account = parts[1].trim();
|
||||||
|
String phone = '';
|
||||||
|
if (parts.length > 2) {
|
||||||
|
phone = parts.sublist(2).join(',').trim();
|
||||||
|
}
|
||||||
if (name.isNotEmpty && account.isNotEmpty) {
|
if (name.isNotEmpty && account.isNotEmpty) {
|
||||||
members.add(
|
members.add(
|
||||||
Recipient(
|
Recipient(
|
||||||
name: name,
|
name: name,
|
||||||
account: account,
|
account: account,
|
||||||
phoneNumber: account,
|
phoneNumber: phone,
|
||||||
latestProviderLabel: '',
|
latestProviderLabel: '',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -78,6 +82,7 @@ class _GroupCreateScreenState extends State<GroupCreateScreen> {
|
|||||||
|
|
||||||
final prefs = await SharedPreferences.getInstance();
|
final prefs = await SharedPreferences.getInstance();
|
||||||
final userId = prefs.getString('userId') ?? '';
|
final userId = prefs.getString('userId') ?? '';
|
||||||
|
final workspaceId = prefs.getString('workspaceId') ?? '';
|
||||||
|
|
||||||
final req = CreateGroupRequest(
|
final req = CreateGroupRequest(
|
||||||
name: _nameController.text.trim(),
|
name: _nameController.text.trim(),
|
||||||
@@ -85,6 +90,7 @@ class _GroupCreateScreenState extends State<GroupCreateScreen> {
|
|||||||
? null
|
? null
|
||||||
: _descriptionController.text.trim(),
|
: _descriptionController.text.trim(),
|
||||||
userId: userId,
|
userId: userId,
|
||||||
|
workspaceId: workspaceId,
|
||||||
recipients: recipients,
|
recipients: recipients,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -95,10 +101,7 @@ class _GroupCreateScreenState extends State<GroupCreateScreen> {
|
|||||||
AppSnackBar.showSuccess(context, 'Group created successfully.');
|
AppSnackBar.showSuccess(context, 'Group created successfully.');
|
||||||
context.pop(true); // Return true to indicate a new group was created
|
context.pop(true); // Return true to indicate a new group was created
|
||||||
} else {
|
} else {
|
||||||
AppSnackBar.showError(
|
AppSnackBar.showError(context, result.error ?? 'Failed to create group.');
|
||||||
context,
|
|
||||||
result.error ?? 'Failed to create group.',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,26 +30,28 @@ class _GroupsScreenState extends State<GroupsScreen> {
|
|||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
if (_scrollController.position.pixels >=
|
if (_scrollController.position.pixels >=
|
||||||
_scrollController.position.maxScrollExtent - 200) {
|
_scrollController.position.maxScrollExtent - 200) {
|
||||||
controller.loadNextPage(userId: prefs.getString('userId') ?? '');
|
controller.loadNextPage(
|
||||||
|
workspaceId: prefs.getString('workspaceId') ?? '',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadData() async {
|
Future<void> _loadData() async {
|
||||||
prefs = await SharedPreferences.getInstance();
|
prefs = await SharedPreferences.getInstance();
|
||||||
final userId = prefs.getString('userId') ?? '';
|
final workspaceId = prefs.getString('workspaceId') ?? '';
|
||||||
await controller.listGroups(userId: userId);
|
await controller.listGroups(workspaceId: workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onSearchChanged(String value) {
|
void _onSearchChanged(String value) {
|
||||||
final userId = prefs.getString('userId') ?? '';
|
final workspaceId = prefs.getString('workspaceId') ?? '';
|
||||||
controller.searchGroups(
|
controller.searchGroups(
|
||||||
userId: userId,
|
workspaceId: workspaceId,
|
||||||
name: value.isNotEmpty ? value : null,
|
name: value.isNotEmpty ? value : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onDeleteSelected() async {
|
void _onDeleteSelected() async {
|
||||||
final userId = prefs.getString('userId') ?? '';
|
final workspaceId = prefs.getString('workspaceId') ?? '';
|
||||||
final count = controller.model.selectedGroupIds.length;
|
final count = controller.model.selectedGroupIds.length;
|
||||||
|
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
@@ -72,7 +74,7 @@ class _GroupsScreenState extends State<GroupsScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true) {
|
if (confirmed == true) {
|
||||||
await controller.deleteSelectedGroups(userId: userId);
|
await controller.deleteSelectedGroups(workspaceId: workspaceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,11 +198,149 @@ class _GroupsScreenState extends State<GroupsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_createGroup() async {
|
_createGroup() {
|
||||||
|
_showCreateGroupBottomSheet();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showCreateGroupBottomSheet() {
|
||||||
|
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (sheetContext) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
|
||||||
|
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Create Group',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: isDark ? Colors.white : Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Choose how you would like to create a group.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildCreateOption(
|
||||||
|
sheetContext,
|
||||||
|
icon: Icons.edit_note,
|
||||||
|
title: 'Create via Form',
|
||||||
|
subtitle: 'Manually enter group details and recipients',
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
final response = await context.push('/groups/create');
|
final response = await context.push('/groups/create');
|
||||||
if (response == true) {
|
if (response == true) {
|
||||||
_loadData();
|
_loadData();
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
isDark: isDark,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildCreateOption(
|
||||||
|
sheetContext,
|
||||||
|
icon: Icons.upload_file,
|
||||||
|
title: 'Create from File',
|
||||||
|
subtitle: 'Upload a CSV file with group & batch data',
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
context.push('/groups/create-from-file');
|
||||||
|
},
|
||||||
|
isDark: isDark,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCreateOption(
|
||||||
|
BuildContext sheetContext, {
|
||||||
|
required IconData icon,
|
||||||
|
required String title,
|
||||||
|
required String subtitle,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
required bool isDark,
|
||||||
|
}) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark
|
||||||
|
? Colors.white.withValues(alpha: 0.05)
|
||||||
|
: Colors.grey.shade50,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark
|
||||||
|
? Colors.white.withValues(alpha: 0.08)
|
||||||
|
: Colors.grey.shade200,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primary.withAlpha(25),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(icon, color: theme.colorScheme.primary, size: 24),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isDark ? Colors.white : Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(Icons.chevron_right, color: Colors.grey.shade400),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildIconButton({
|
Widget _buildIconButton({
|
||||||
@@ -295,27 +435,43 @@ class _GroupsScreenState extends State<GroupsScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildGroupCard(RecipientGroup group) {
|
Widget _buildGroupCard(RecipientGroup group) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
final isSelected = controller.model.selectedGroupIds.contains(group.id);
|
final isSelected = controller.model.selectedGroupIds.contains(group.id);
|
||||||
final inSelectMode = controller.model.selectMode;
|
final inSelectMode = controller.model.selectMode;
|
||||||
|
|
||||||
return Card(
|
return Padding(
|
||||||
elevation: 0,
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
shape: RoundedRectangleBorder(
|
child: Material(
|
||||||
borderRadius: BorderRadius.circular(12),
|
color: Colors.transparent,
|
||||||
side: BorderSide(
|
|
||||||
color: inSelectMode && isSelected
|
|
||||||
? Theme.of(context).colorScheme.primary
|
|
||||||
: Colors.black12.withAlpha(30),
|
|
||||||
width: inSelectMode && isSelected ? 2 : 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(16),
|
||||||
onTap: inSelectMode
|
onTap: inSelectMode
|
||||||
? () => controller.toggleGroupSelection(group.id!)
|
? () => controller.toggleGroupSelection(group.id!)
|
||||||
: () => context.push('/groups/${group.id}', extra: group),
|
: () => context.push('/groups/${group.id}', extra: group),
|
||||||
child: Padding(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: inSelectMode && isSelected
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: isDark
|
||||||
|
? Colors.white.withValues(alpha: 0.06)
|
||||||
|
: Colors.grey.shade200,
|
||||||
|
width: inSelectMode && isSelected ? 2 : 1,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: isDark
|
||||||
|
? Colors.black.withValues(alpha: 0.2)
|
||||||
|
: Colors.black.withValues(alpha: 0.03),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (inSelectMode)
|
if (inSelectMode)
|
||||||
@@ -326,33 +482,39 @@ class _GroupsScreenState extends State<GroupsScreen> {
|
|||||||
? Icons.check_box
|
? Icons.check_box
|
||||||
: Icons.check_box_outline_blank,
|
: Icons.check_box_outline_blank,
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? Theme.of(context).colorScheme.primary
|
? theme.colorScheme.primary
|
||||||
: Colors.grey.shade400,
|
: Colors.grey.shade400,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
CircleAvatar(
|
Container(
|
||||||
backgroundColor: Theme.of(
|
width: 44,
|
||||||
context,
|
height: 44,
|
||||||
).colorScheme.primary.withAlpha(30),
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primary.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
_getGroupAbbreviation(group.name),
|
_getGroupAbbreviation(group.name),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.primary,
|
color: theme.colorScheme.primary,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
),
|
||||||
|
const SizedBox(width: 14),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
group.name,
|
group.name,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isDark ? Colors.white : Colors.black87,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (group.description != null &&
|
if (group.description != null &&
|
||||||
@@ -362,8 +524,11 @@ class _GroupsScreenState extends State<GroupsScreen> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
group.description!,
|
group.description!,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
color: Colors.grey.shade600,
|
fontWeight: FontWeight.w400,
|
||||||
|
color: isDark
|
||||||
|
? Colors.white54
|
||||||
|
: Colors.grey.shade500,
|
||||||
),
|
),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
@@ -378,6 +543,7 @@ class _GroupsScreenState extends State<GroupsScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
import 'package:qpay/config/app_config.dart';
|
||||||
import 'package:qpay/http/http.dart';
|
import 'package:qpay/http/http.dart';
|
||||||
import 'package:qpay/models/api_response.dart';
|
import 'package:qpay/models/api_response.dart';
|
||||||
import 'package:qpay/models/pageable_model.dart';
|
import 'package:qpay/models/pageable_model.dart';
|
||||||
@@ -66,7 +69,7 @@ class HistoryController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
_safeNotify();
|
_safeNotify();
|
||||||
|
|
||||||
params['partyType'] = 'INDIVIDUAL';
|
params['partyType'] = 'INDIVIDUAL,GROUP';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Map<String, dynamic> response = await http.get(
|
Map<String, dynamic> response = await http.get(
|
||||||
@@ -107,27 +110,22 @@ class HistoryController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> searchTransactions(
|
Future<void> searchTransactions(
|
||||||
String userId, {
|
String workspaceId, {
|
||||||
String? search,
|
String? billName,
|
||||||
String? status,
|
String? status,
|
||||||
String? type,
|
String? type,
|
||||||
}) async {
|
}) async {
|
||||||
model.searchQuery = search ?? '';
|
model.searchQuery = billName ?? '';
|
||||||
model.statusFilter = status;
|
model.statusFilter = status;
|
||||||
model.typeFilter = type;
|
model.typeFilter = type;
|
||||||
final params = <String, String>{
|
final params = <String, String>{
|
||||||
'userId': userId,
|
'workspaceId': workspaceId,
|
||||||
'page': '0',
|
'page': '0',
|
||||||
'size': '20',
|
'size': '20',
|
||||||
'sort': 'createdAt,desc',
|
'sort': 'createdAt,desc',
|
||||||
};
|
};
|
||||||
if (search != null && search.isNotEmpty) {
|
if (billName != null && billName.isNotEmpty) {
|
||||||
params['creditAccount'] = search;
|
params['billName'] = billName;
|
||||||
params['creditEmail'] = search;
|
|
||||||
params['creditPhone'] = search;
|
|
||||||
params['creditName'] = search;
|
|
||||||
params['billName'] = search;
|
|
||||||
params['amount'] = search;
|
|
||||||
}
|
}
|
||||||
if (status != null && status.isNotEmpty) {
|
if (status != null && status.isNotEmpty) {
|
||||||
params['status'] = status;
|
params['status'] = status;
|
||||||
@@ -138,23 +136,18 @@ class HistoryController extends ChangeNotifier {
|
|||||||
await getTransactions(params);
|
await getTransactions(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadNextPage(String userId) async {
|
Future<void> loadNextPage(String workspaceId) async {
|
||||||
if (model.isLoadingMore || model.currentPage + 1 >= model.totalPages) {
|
if (model.isLoadingMore || model.currentPage + 1 >= model.totalPages) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final params = <String, String>{
|
final params = <String, String>{
|
||||||
'userId': userId,
|
'workspaceId': workspaceId,
|
||||||
'page': (model.currentPage + 1).toString(),
|
'page': (model.currentPage + 1).toString(),
|
||||||
'size': '20',
|
'size': '20',
|
||||||
'sort': 'createdAt,desc',
|
'sort': 'createdAt,desc',
|
||||||
};
|
};
|
||||||
if (model.searchQuery.isNotEmpty) {
|
if (model.searchQuery.isNotEmpty) {
|
||||||
params['creditAccount'] = model.searchQuery;
|
|
||||||
params['creditEmail'] = model.searchQuery;
|
|
||||||
params['creditPhone'] = model.searchQuery;
|
|
||||||
params['creditName'] = model.searchQuery;
|
|
||||||
params['billName'] = model.searchQuery;
|
params['billName'] = model.searchQuery;
|
||||||
params['amount'] = model.searchQuery;
|
|
||||||
}
|
}
|
||||||
if (model.statusFilter != null && model.statusFilter!.isNotEmpty) {
|
if (model.statusFilter != null && model.statusFilter!.isNotEmpty) {
|
||||||
params['status'] = model.statusFilter!;
|
params['status'] = model.statusFilter!;
|
||||||
@@ -193,6 +186,58 @@ class HistoryController extends ChangeNotifier {
|
|||||||
_safeNotify();
|
_safeNotify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the fully-qualified download URL for the transactions report.
|
||||||
|
Uri _buildReportUrl({
|
||||||
|
required String workspaceId,
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
}) {
|
||||||
|
final df = DateFormat("yyyy-MM-dd'T'HH:mm:ss");
|
||||||
|
final start = Uri.encodeComponent(
|
||||||
|
df.format(
|
||||||
|
DateTime(startDate.year, startDate.month, startDate.day, 0, 0, 0),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final end = Uri.encodeComponent(
|
||||||
|
df.format(
|
||||||
|
DateTime(endDate.year, endDate.month, endDate.day, 23, 59, 59),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return Uri.parse(
|
||||||
|
'${AppConfig.baseUrl}/public/reports/transactions/download'
|
||||||
|
'?startDate=$start'
|
||||||
|
'&endDate=$end'
|
||||||
|
'&workspaceId=$workspaceId',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the transactions report download URL in the device browser.
|
||||||
|
/// The browser handles the actual file download natively — no file I/O
|
||||||
|
/// or plugin channels required, works on Android, iOS, and web.
|
||||||
|
Future<void> downloadReport({
|
||||||
|
required String workspaceId,
|
||||||
|
required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
}) async {
|
||||||
|
model.isActing = true;
|
||||||
|
_safeNotify();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final uri = _buildReportUrl(
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
startDate: startDate,
|
||||||
|
endDate: endDate,
|
||||||
|
);
|
||||||
|
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||||
|
} catch (e) {
|
||||||
|
logger.e('Failed to open report URL: $e');
|
||||||
|
model.errorMessage = 'Failed to download report';
|
||||||
|
} finally {
|
||||||
|
model.isActing = false;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void clearFilters() {
|
void clearFilters() {
|
||||||
model.statusFilter = null;
|
model.statusFilter = null;
|
||||||
model.typeFilter = null;
|
model.typeFilter = null;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:qpay/auth_state.dart';
|
||||||
import 'package:qpay/models/responsive_policy.dart';
|
import 'package:qpay/models/responsive_policy.dart';
|
||||||
import 'package:qpay/screens/receipt/receipt_controller.dart';
|
import 'package:qpay/screens/receipt/receipt_controller.dart';
|
||||||
import 'package:qpay/screens/transaction_controller.dart';
|
import 'package:qpay/screens/transaction_controller.dart';
|
||||||
@@ -64,16 +66,16 @@ class _HistoryScreenState extends State<HistoryScreen>
|
|||||||
|
|
||||||
Future<void> setupData() async {
|
Future<void> setupData() async {
|
||||||
prefs = await SharedPreferences.getInstance();
|
prefs = await SharedPreferences.getInstance();
|
||||||
final userId = prefs.getString("userId") ?? '';
|
final workspaceId = prefs.getString("workspaceId") ?? '';
|
||||||
await controller.searchTransactions(userId);
|
await controller.searchTransactions(workspaceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
if (_scrollController.position.pixels >=
|
if (_scrollController.position.pixels >=
|
||||||
_scrollController.position.maxScrollExtent - 200) {
|
_scrollController.position.maxScrollExtent - 200) {
|
||||||
final userId = prefs.getString("userId") ?? '';
|
final workspaceId = prefs.getString("workspaceId") ?? '';
|
||||||
if (userId.isNotEmpty) {
|
if (workspaceId.isNotEmpty) {
|
||||||
controller.loadNextPage(userId);
|
controller.loadNextPage(workspaceId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,10 +229,11 @@ class _HistoryScreenState extends State<HistoryScreen>
|
|||||||
child: ElevatedButton(
|
child: ElevatedButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Navigator.of(ctx).pop();
|
Navigator.of(ctx).pop();
|
||||||
final userId = prefs.getString("userId") ?? '';
|
final workspaceId =
|
||||||
|
prefs.getString("workspaceId") ?? '';
|
||||||
controller.searchTransactions(
|
controller.searchTransactions(
|
||||||
userId,
|
workspaceId,
|
||||||
search: _searchController.text.isNotEmpty
|
billName: _searchController.text.isNotEmpty
|
||||||
? _searchController.text
|
? _searchController.text
|
||||||
: null,
|
: null,
|
||||||
status: controller.model.statusFilter,
|
status: controller.model.statusFilter,
|
||||||
@@ -250,6 +253,142 @@ class _HistoryScreenState extends State<HistoryScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shows the report download bottom sheet with start/end date pickers.
|
||||||
|
void _showReportSheet() {
|
||||||
|
DateTime selectedStart = DateTime.now().subtract(const Duration(days: 7));
|
||||||
|
DateTime selectedEnd = DateTime.now();
|
||||||
|
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (ctx) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setSheetState) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
top: 20,
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom + 20,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Download Report',
|
||||||
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
_buildDateField(
|
||||||
|
label: 'Start Date',
|
||||||
|
date: selectedStart,
|
||||||
|
onTap: () async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: selectedStart,
|
||||||
|
firstDate: DateTime(2020),
|
||||||
|
lastDate: selectedEnd,
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
setSheetState(() => selectedStart = picked);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
_buildDateField(
|
||||||
|
label: 'End Date',
|
||||||
|
date: selectedEnd,
|
||||||
|
onTap: () async {
|
||||||
|
final picked = await showDatePicker(
|
||||||
|
context: context,
|
||||||
|
initialDate: selectedEnd,
|
||||||
|
firstDate: selectedStart,
|
||||||
|
lastDate: DateTime.now(),
|
||||||
|
);
|
||||||
|
if (picked != null) {
|
||||||
|
setSheetState(() => selectedEnd = picked);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
icon: controller.model.isActing
|
||||||
|
? const SizedBox(
|
||||||
|
width: 18,
|
||||||
|
height: 18,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.download),
|
||||||
|
label: Text(
|
||||||
|
controller.model.isActing
|
||||||
|
? 'Downloading…'
|
||||||
|
: 'Download Report',
|
||||||
|
),
|
||||||
|
onPressed: controller.model.isActing
|
||||||
|
? null
|
||||||
|
: () {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
final workspaceId =
|
||||||
|
prefs.getString("workspaceId") ?? '';
|
||||||
|
controller.downloadReport(
|
||||||
|
workspaceId: workspaceId,
|
||||||
|
startDate: selectedStart,
|
||||||
|
endDate: selectedEnd,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a tappable date field for the report sheet.
|
||||||
|
Widget _buildDateField({
|
||||||
|
required String label,
|
||||||
|
required DateTime date,
|
||||||
|
required VoidCallback onTap,
|
||||||
|
}) {
|
||||||
|
final df = DateFormat('dd MMM yyyy');
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade100,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'$label: ${df.format(date)}',
|
||||||
|
style: const TextStyle(fontSize: 15),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(Icons.calendar_today, size: 20, color: Colors.grey.shade600),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
@@ -327,9 +466,10 @@ class _HistoryScreenState extends State<HistoryScreen>
|
|||||||
icon: const Icon(Icons.clear),
|
icon: const Icon(Icons.clear),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
final userId = prefs.getString("userId") ?? '';
|
final workspaceId =
|
||||||
|
prefs.getString("workspaceId") ?? '';
|
||||||
controller.searchTransactions(
|
controller.searchTransactions(
|
||||||
userId,
|
workspaceId,
|
||||||
status: controller.model.statusFilter,
|
status: controller.model.statusFilter,
|
||||||
type: controller.model.typeFilter,
|
type: controller.model.typeFilter,
|
||||||
);
|
);
|
||||||
@@ -351,10 +491,10 @@ class _HistoryScreenState extends State<HistoryScreen>
|
|||||||
),
|
),
|
||||||
textInputAction: TextInputAction.search,
|
textInputAction: TextInputAction.search,
|
||||||
onFieldSubmitted: (value) {
|
onFieldSubmitted: (value) {
|
||||||
final userId = prefs.getString("userId") ?? '';
|
final workspaceId = prefs.getString("workspaceId") ?? '';
|
||||||
controller.searchTransactions(
|
controller.searchTransactions(
|
||||||
userId,
|
workspaceId,
|
||||||
search: value.isNotEmpty ? value : null,
|
billName: value.isNotEmpty ? value : null,
|
||||||
status: controller.model.statusFilter,
|
status: controller.model.statusFilter,
|
||||||
type: controller.model.typeFilter,
|
type: controller.model.typeFilter,
|
||||||
);
|
);
|
||||||
@@ -394,6 +534,15 @@ class _HistoryScreenState extends State<HistoryScreen>
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
// Report download — only visible to logged-in users.
|
||||||
|
if (authState.isLoggedIn)
|
||||||
|
_buildIconButton(
|
||||||
|
icon: Icons.description_outlined,
|
||||||
|
tooltip: 'Download report',
|
||||||
|
isActive: false,
|
||||||
|
onPressed: _showReportSheet,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
_buildIconButton(
|
_buildIconButton(
|
||||||
icon: Icons.add,
|
icon: Icons.add,
|
||||||
tooltip: 'New payment',
|
tooltip: 'New payment',
|
||||||
@@ -496,10 +645,10 @@ class _HistoryScreenState extends State<HistoryScreen>
|
|||||||
'Status: ${controller.model.statusFilter}',
|
'Status: ${controller.model.statusFilter}',
|
||||||
() {
|
() {
|
||||||
controller.model.statusFilter = null;
|
controller.model.statusFilter = null;
|
||||||
final userId = prefs.getString("userId") ?? '';
|
final workspaceId = prefs.getString("workspaceId") ?? '';
|
||||||
controller.searchTransactions(
|
controller.searchTransactions(
|
||||||
userId,
|
workspaceId,
|
||||||
search: _searchController.text.isNotEmpty
|
billName: _searchController.text.isNotEmpty
|
||||||
? _searchController.text
|
? _searchController.text
|
||||||
: null,
|
: null,
|
||||||
type: controller.model.typeFilter,
|
type: controller.model.typeFilter,
|
||||||
@@ -509,10 +658,10 @@ class _HistoryScreenState extends State<HistoryScreen>
|
|||||||
if (controller.model.typeFilter != null)
|
if (controller.model.typeFilter != null)
|
||||||
_activeFilterChip('Type: ${controller.model.typeFilter}', () {
|
_activeFilterChip('Type: ${controller.model.typeFilter}', () {
|
||||||
controller.model.typeFilter = null;
|
controller.model.typeFilter = null;
|
||||||
final userId = prefs.getString("userId") ?? '';
|
final workspaceId = prefs.getString("workspaceId") ?? '';
|
||||||
controller.searchTransactions(
|
controller.searchTransactions(
|
||||||
userId,
|
workspaceId,
|
||||||
search: _searchController.text.isNotEmpty
|
billName: _searchController.text.isNotEmpty
|
||||||
? _searchController.text
|
? _searchController.text
|
||||||
: null,
|
: null,
|
||||||
status: controller.model.statusFilter,
|
status: controller.model.statusFilter,
|
||||||
|
|||||||
@@ -148,14 +148,14 @@ class HomeController extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<ApiResponse<List<Map<String, dynamic>>>> getTransactions(
|
Future<ApiResponse<List<Map<String, dynamic>>>> getTransactions(
|
||||||
String userId,
|
String workspaceId,
|
||||||
) async {
|
) async {
|
||||||
model.isLoading = true;
|
model.isLoading = true;
|
||||||
if (!_disposed) notifyListeners();
|
if (!_disposed) notifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
Map<String, dynamic> response = await http.get(
|
Map<String, dynamic> response = await http.get(
|
||||||
'/public/transaction?sort=createdAt,desc&size=3&page=0&partyType=INDIVIDUAL&userId=$userId',
|
'/public/transaction?sort=createdAt,desc&size=3&page=0&partyType=INDIVIDUAL,GROUP&workspaceId=$workspaceId',
|
||||||
);
|
);
|
||||||
|
|
||||||
PageableModel pageableModel = PageableModel.fromJson(response);
|
PageableModel pageableModel = PageableModel.fromJson(response);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -8,7 +9,6 @@ import 'package:qpay/screens/home/home_controller.dart';
|
|||||||
import 'package:qpay/screens/receipt/receipt_controller.dart';
|
import 'package:qpay/screens/receipt/receipt_controller.dart';
|
||||||
import 'package:qpay/screens/transaction_controller.dart';
|
import 'package:qpay/screens/transaction_controller.dart';
|
||||||
import 'package:qpay/screens/transactions/transaction_model.dart' as txn;
|
import 'package:qpay/screens/transactions/transaction_model.dart' as txn;
|
||||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
|
||||||
import 'package:qpay/widgets/transaction_item_widget.dart';
|
import 'package:qpay/widgets/transaction_item_widget.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
@@ -28,8 +28,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
late final router = GoRouter.of(context);
|
late final router = GoRouter.of(context);
|
||||||
|
|
||||||
bool _isLoggedIn = false;
|
bool _isLoggedIn = false;
|
||||||
bool _isZWGSelected = false;
|
|
||||||
String initials = '';
|
String initials = '';
|
||||||
|
String _selectedCurrency = 'USD';
|
||||||
|
|
||||||
|
/// Incremented to force [AccountBalanceWidget] to recreate and re-read
|
||||||
|
/// auth state from disk (e.g. after logout or manual refresh).
|
||||||
|
int _balanceWidgetRefreshKey = 0;
|
||||||
|
|
||||||
late AnimationController _providersAnimController;
|
late AnimationController _providersAnimController;
|
||||||
late Animation<Offset> _providersSlideAnimation;
|
late Animation<Offset> _providersSlideAnimation;
|
||||||
@@ -107,32 +111,55 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
homeController = HomeController(context);
|
homeController = HomeController(context);
|
||||||
await homeController.getAccounts();
|
await homeController.getAccounts();
|
||||||
await homeController.getCategories();
|
await homeController.getCategories();
|
||||||
await homeController.getProviders(_isZWGSelected ? "ZWG" : "USD");
|
await homeController.getProviders(_selectedCurrency);
|
||||||
|
|
||||||
|
transactionController.model.formData = transactionController.model.formData
|
||||||
|
.copyWith(debitCurrency: _selectedCurrency);
|
||||||
|
|
||||||
prefs = await SharedPreferences.getInstance();
|
prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
if (prefs.getString("token") != null) {
|
if (prefs.getString("token") != null) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoggedIn = true;
|
_isLoggedIn = true;
|
||||||
initials = prefs.getString("initials")!;
|
initials = prefs.getString("workspaceInitials")!;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (prefs.getString("workspaceId") == null) {
|
||||||
|
prefs.setString("workspaceId", Uuid().v4());
|
||||||
|
}
|
||||||
if (prefs.getString("userId") == null) {
|
if (prefs.getString("userId") == null) {
|
||||||
prefs.setString("userId", Uuid().v4());
|
prefs.setString("userId", Uuid().v4());
|
||||||
}
|
}
|
||||||
|
|
||||||
await homeController.getTransactions(prefs.getString("userId")!);
|
await homeController.getTransactions(prefs.getString("workspaceId")!);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onCurrencySelected(String currency) {
|
||||||
|
if (_selectedCurrency == currency) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_selectedCurrency = currency;
|
||||||
|
});
|
||||||
|
|
||||||
|
homeController.getProviders(currency);
|
||||||
|
|
||||||
|
transactionController.updateCurrency(currency);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onRefresh() async {
|
Future<void> _onRefresh() async {
|
||||||
|
setState(() {
|
||||||
|
_balanceWidgetRefreshKey++;
|
||||||
|
});
|
||||||
await homeController.getAccounts();
|
await homeController.getAccounts();
|
||||||
await homeController.getCategories();
|
await homeController.getCategories();
|
||||||
await homeController.getProviders(_isZWGSelected ? "ZWG" : "USD");
|
await homeController.getProviders(_selectedCurrency);
|
||||||
|
|
||||||
prefs = await SharedPreferences.getInstance();
|
prefs = await SharedPreferences.getInstance();
|
||||||
if (prefs.getString("userId") != null) {
|
if (prefs.getString("workspaceId") != null) {
|
||||||
await homeController.getTransactions(prefs.getString("userId")!);
|
await homeController.getTransactions(prefs.getString("workspaceId")!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,19 +168,34 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
if (location.startsWith('/home')) {
|
if (location.startsWith('/home')) {
|
||||||
prefs = await SharedPreferences.getInstance();
|
prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
|
if (prefs.getString("workspaceId") == null) {
|
||||||
|
prefs.setString("workspaceId", Uuid().v4());
|
||||||
|
}
|
||||||
if (prefs.getString("userId") == null) {
|
if (prefs.getString("userId") == null) {
|
||||||
prefs.setString("userId", Uuid().v4());
|
prefs.setString("userId", Uuid().v4());
|
||||||
}
|
}
|
||||||
|
|
||||||
await homeController.getTransactions(prefs.getString("userId")!);
|
await homeController.getTransactions(prefs.getString("workspaceId")!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the repeat action for a transaction.
|
/// Handles the repeat action for a transaction.
|
||||||
Future<void> _repeatTransaction(Map<String, dynamic> item) async {
|
Future<void> _repeatTransaction(Map<String, dynamic> item) async {
|
||||||
|
try {
|
||||||
final receiptController = ReceiptController(context);
|
final receiptController = ReceiptController(context);
|
||||||
final transaction = txn.TransactionModel.fromJson(item);
|
final transaction = txn.TransactionModel.fromJson(item);
|
||||||
await receiptController.repeatTransaction(transaction);
|
await receiptController.repeatTransaction(transaction);
|
||||||
|
} catch (e, s) {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print(e);
|
||||||
|
print(s);
|
||||||
|
}
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('Error repeating transaction')));
|
||||||
|
}
|
||||||
|
}
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
context.push('/make-payment');
|
context.push('/make-payment');
|
||||||
}
|
}
|
||||||
@@ -311,6 +353,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
end: Alignment.bottomLeft,
|
end: Alignment.bottomLeft,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
child: InkWell(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
initials,
|
initials,
|
||||||
@@ -321,14 +364,17 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
onTap: () => context.go('/workspace'),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
SizedBox(width: 5),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.logout_rounded,
|
Icons.logout,
|
||||||
color: isDark ? Colors.white54 : Colors.black54,
|
color: isDark ? Colors.white54 : Colors.black54,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
onPressed: _showLogoutDialog,
|
onPressed: () => _showLogoutDialog(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
@@ -364,120 +410,15 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (!_isLoggedIn) _buildLoginPrompt(theme, isDark),
|
AccountBalanceWidget(
|
||||||
if (_isLoggedIn) const AccountBalanceWidget(),
|
key: ValueKey(_balanceWidgetRefreshKey),
|
||||||
const SizedBox(height: 16),
|
selectedCurrency: _selectedCurrency,
|
||||||
|
onCurrencySelected: _onCurrencySelected,
|
||||||
|
),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
_buildSectionLabel(theme, isDark, Icons.grid_view_rounded, "Pay"),
|
_buildSectionLabel(theme, isDark, Icons.grid_view_rounded, "Pay"),
|
||||||
Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 8,
|
|
||||||
vertical: 2,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isDark ? const Color(0xFF1E1E1E) : Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
border: Border.all(
|
|
||||||
color: isDark
|
|
||||||
? Colors.white.withValues(alpha: 0.12)
|
|
||||||
: Colors.black.withValues(alpha: 0.08),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'USD',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: !_isZWGSelected
|
|
||||||
? theme.colorScheme.primary
|
|
||||||
: isDark
|
|
||||||
? Colors.white54
|
|
||||||
: Colors.black45,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
setState(() {
|
|
||||||
_isZWGSelected = !_isZWGSelected;
|
|
||||||
});
|
|
||||||
if (_isZWGSelected) {
|
|
||||||
AppSnackBar.show(
|
|
||||||
context,
|
|
||||||
'ZWG currency not supported yet',
|
|
||||||
customContent: const Center(
|
|
||||||
child: Text('ZWG currency not supported yet'),
|
|
||||||
),
|
|
||||||
backgroundColor: theme.colorScheme.primary,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
duration: const Duration(seconds: 3),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future.delayed(
|
|
||||||
const Duration(milliseconds: 1000),
|
|
||||||
() {
|
|
||||||
setState(() {
|
|
||||||
_isZWGSelected = !_isZWGSelected;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
width: 32,
|
|
||||||
height: 18,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(10),
|
|
||||||
color: _isZWGSelected
|
|
||||||
? theme.colorScheme.primary
|
|
||||||
: Colors.grey.shade300,
|
|
||||||
),
|
|
||||||
child: AnimatedAlign(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
alignment: _isZWGSelected
|
|
||||||
? Alignment.centerRight
|
|
||||||
: Alignment.centerLeft,
|
|
||||||
child: Container(
|
|
||||||
width: 14,
|
|
||||||
height: 14,
|
|
||||||
margin: const EdgeInsets.all(2),
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'ZWG',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: _isZWGSelected
|
|
||||||
? theme.colorScheme.primary
|
|
||||||
: isDark
|
|
||||||
? Colors.white54
|
|
||||||
: Colors.black45,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
Icons.refresh_rounded,
|
Icons.refresh_rounded,
|
||||||
@@ -489,8 +430,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Skeletonizer(
|
Skeletonizer(
|
||||||
enabled: homeController.model.isLoading,
|
enabled: homeController.model.isLoading,
|
||||||
@@ -743,67 +682,6 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────
|
|
||||||
// Login Prompt
|
|
||||||
// ──────────────────────────────────────────────────────────────
|
|
||||||
Widget _buildLoginPrompt(ThemeData theme, bool isDark) {
|
|
||||||
return Container(
|
|
||||||
width: double.infinity,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [
|
|
||||||
theme.colorScheme.primary.withValues(alpha: 0.12),
|
|
||||||
theme.colorScheme.primary.withValues(alpha: 0.04),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(
|
|
||||||
color: theme.colorScheme.primary.withValues(alpha: 0.15),
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Register or Login for more features',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: isDark ? Colors.white : Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () {
|
|
||||||
context.push('/onboarding/landing');
|
|
||||||
},
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: theme.colorScheme.primary,
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
elevation: 0,
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
'Get Started',
|
|
||||||
style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
// Logout Dialog
|
// Logout Dialog
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
@@ -831,6 +709,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
prefs.clear();
|
prefs.clear();
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoggedIn = false;
|
_isLoggedIn = false;
|
||||||
|
_balanceWidgetRefreshKey++;
|
||||||
});
|
});
|
||||||
authState.refresh();
|
authState.refresh();
|
||||||
setupDataAndTransitions();
|
setupDataAndTransitions();
|
||||||
|
|||||||
149
lib/screens/more/more_screen.dart
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../../auth_state.dart';
|
||||||
|
import '../../models/responsive_policy.dart';
|
||||||
|
|
||||||
|
class MoreScreen extends StatefulWidget {
|
||||||
|
const MoreScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MoreScreen> createState() => _MoreScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MoreScreenState extends State<MoreScreen> {
|
||||||
|
late SharedPreferences _prefs;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_initPrefs();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _initPrefs() async {
|
||||||
|
_prefs = await SharedPreferences.getInstance();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showLogoutDialog() {
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (BuildContext dialogContext) {
|
||||||
|
return AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
title: const Text('Confirm Logout'),
|
||||||
|
content: const Text('Are you sure you want to log out?'),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
child: const Text('Logout'),
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(dialogContext).pop();
|
||||||
|
_prefs.clear();
|
||||||
|
authState.refresh();
|
||||||
|
context.go("/");
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('More'), centerTitle: false),
|
||||||
|
body: Center(
|
||||||
|
child: LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
|
||||||
|
? 600.0
|
||||||
|
: double.infinity;
|
||||||
|
|
||||||
|
return SizedBox(
|
||||||
|
width: maxWidth,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_NavOptionTile(
|
||||||
|
icon: Icons.swap_horiz,
|
||||||
|
title: 'Change Workspace',
|
||||||
|
subtitle: 'Switch to a different workspace',
|
||||||
|
onTap: () => context.go('/workspace'),
|
||||||
|
),
|
||||||
|
_NavOptionTile(
|
||||||
|
icon: Icons.people_outline,
|
||||||
|
title: 'Users',
|
||||||
|
subtitle: 'Manage users and permissions',
|
||||||
|
onTap: () => context.go('/users'),
|
||||||
|
),
|
||||||
|
_NavOptionTile(
|
||||||
|
icon: Icons.monitor_heart_outlined,
|
||||||
|
title: 'Uptime Status',
|
||||||
|
subtitle: 'View system uptime and service status',
|
||||||
|
onTap: () => context.push('/uptime'),
|
||||||
|
),
|
||||||
|
_NavOptionTile(
|
||||||
|
icon: Icons.support_agent_rounded,
|
||||||
|
title: 'Contact Us',
|
||||||
|
subtitle: 'Get in touch with our team',
|
||||||
|
onTap: () => context.push('/contact'),
|
||||||
|
),
|
||||||
|
_NavOptionTile(
|
||||||
|
icon: Icons.logout,
|
||||||
|
title: 'Logout',
|
||||||
|
subtitle: 'Sign out of your account',
|
||||||
|
onTap: () => _showLogoutDialog(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NavOptionTile extends StatelessWidget {
|
||||||
|
const _NavOptionTile({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
leading: Icon(icon, size: 28, color: theme.colorScheme.primary),
|
||||||
|
title: Text(title, style: theme.textTheme.titleMedium),
|
||||||
|
subtitle: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(subtitle, style: theme.textTheme.bodySmall),
|
||||||
|
),
|
||||||
|
trailing: Icon(
|
||||||
|
Icons.chevron_right,
|
||||||
|
color: theme.colorScheme.onSurface.withAlpha(100),
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
lib/screens/onboarding/auth_provider.dart
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../../models/user_model.dart';
|
||||||
|
|
||||||
|
class AuthProvider {
|
||||||
|
// get the user model from shared preferences
|
||||||
|
static Future<UserModel?> getUserModel() async {
|
||||||
|
SharedPreferences prefs = await SharedPreferences.getInstance();
|
||||||
|
String? userModelString = prefs.getString("userModel");
|
||||||
|
if (userModelString != null) {
|
||||||
|
Map<String, dynamic> userModelJson = jsonDecode(userModelString);
|
||||||
|
return UserModel.fromJson(userModelJson);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,7 +104,7 @@ class _LandingScreenState extends State<LandingScreen> {
|
|||||||
Center(
|
Center(
|
||||||
child: TextButton(
|
child: TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.go('/');
|
context.go('/workspace');
|
||||||
},
|
},
|
||||||
child: Text(
|
child: Text(
|
||||||
'Continue as guest?',
|
'Continue as guest?',
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:qpay/auth_state.dart';
|
import 'package:qpay/auth_state.dart';
|
||||||
@@ -5,6 +7,8 @@ import 'package:qpay/http/http.dart';
|
|||||||
import 'package:qpay/models/api_response.dart';
|
import 'package:qpay/models/api_response.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
|
import '../../../models/user_model.dart';
|
||||||
|
|
||||||
class LoginScreenModel {
|
class LoginScreenModel {
|
||||||
String username = "";
|
String username = "";
|
||||||
String password = "";
|
String password = "";
|
||||||
@@ -72,6 +76,9 @@ class LoginController extends ChangeNotifier {
|
|||||||
: ''),
|
: ''),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
UserModel userModel = UserModel.fromJson(response);
|
||||||
|
prefs.setString("userModel", jsonEncode(userModel));
|
||||||
|
|
||||||
// Notify the router that the user is now signed in so any
|
// Notify the router that the user is now signed in so any
|
||||||
// auth-gated redirects (e.g. the groups flow) are re-evaluated
|
// auth-gated redirects (e.g. the groups flow) are re-evaluated
|
||||||
// and the user is sent to their originally requested page.
|
// and the user is sent to their originally requested page.
|
||||||
|
|||||||