Compare commits
10 Commits
3023bc313c
...
ea04dea7bc
| Author | SHA1 | Date | |
|---|---|---|---|
| ea04dea7bc | |||
| f04fc15fb9 | |||
| dad09202c4 | |||
| c253a69566 | |||
| eed7415ef3 | |||
| 1db6641f2c | |||
| 55921f6ee3 | |||
| 17db608dbc | |||
| eb774b72f6 | |||
| a1a7f3a4d6 |
@@ -40,7 +40,8 @@ flutter run --dart-define=APP_ENV=live
|
||||
|
||||
```bash
|
||||
flutter build web --dart-define=APP_ENV=live
|
||||
flutter build apk --dart-define=APP_ENV=live
|
||||
flutter build apk --dart-define=APP_ENV=live --dart-define=BASE_URL=https://payapi.velocityafrica.net/api
|
||||
flutter build appbundle --dart-define-from-file=config.json
|
||||
```
|
||||
|
||||
### Quick pre-release checks
|
||||
|
||||
@@ -1,18 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<!-- Remove this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- App Logo -->
|
||||
<item
|
||||
android:width="100dp"
|
||||
android:height="100dp"
|
||||
android:gravity="center">
|
||||
|
||||
<bitmap
|
||||
android:gravity="fill"
|
||||
android:src="@mipmap/ic_launcher"
|
||||
android:mipMap="true"/>
|
||||
|
||||
</item>
|
||||
<item android:drawable="@color/splash_background" />
|
||||
</layer-list>
|
||||
|
||||
@@ -1,17 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<!-- Remove this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@color/splash_background" />
|
||||
|
||||
<!-- App Logo -->
|
||||
<item
|
||||
android:width="100dp"
|
||||
android:height="100dp"
|
||||
android:gravity="center">
|
||||
|
||||
<bitmap
|
||||
android:gravity="fill"
|
||||
android:src="@mipmap/ic_launcher"
|
||||
android:mipMap="true"/>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
||||
4
config.json
Normal file
4
config.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"APP_ENV": "live",
|
||||
"BASE_URL": "https://payapi.velocityafrica.net/api"
|
||||
}
|
||||
317
docs/PRODUCT_SPEC.md
Normal file
317
docs/PRODUCT_SPEC.md
Normal file
@@ -0,0 +1,317 @@
|
||||
# Velocity Pay — Product Specification
|
||||
|
||||
**Version:** 1.0.1
|
||||
**Last Updated:** January 2026
|
||||
**Product Owner:** Velocity Africa (Qantra)
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
Velocity Pay is a multi-platform bill-payment application that enables individuals and businesses in Zimbabwe to pay utilities, purchase airtime, and send batch payments through a single, streamlined experience. The application supports EcoCash, Mastercard/Visa (MPGS), and wallet-based payment processors, and is available across Android, iOS, and the web.
|
||||
|
||||
Built with Flutter, Velocity Pay delivers a consistent, high-quality user experience on mobile devices and desktop browsers alike. It is designed from the ground up to support multi-workspace / multi-tenant scenarios, making it suitable for both individual consumers and organisations that manage multiple business entities.
|
||||
|
||||
---
|
||||
|
||||
## 2. Target Audience
|
||||
|
||||
| Segment | Description |
|
||||
|---------|-------------|
|
||||
| **Individual consumers** | Pay utility bills (ZESA electricity, water), purchase airtime (Econet, NetOne, Telecel), and top up mobile wallets. |
|
||||
| **Small businesses** | Manage payments across a single workspace with history tracking and repeat transactions. |
|
||||
| **Enterprises / NGOs** | Use batch payments via recipient **Groups** to disburse funds to many recipients at once (e.g. payroll, beneficiary disbursements). |
|
||||
| **Developers / Integrators** | Organisations that need to embed payment functionality into their own systems can reference Velocity Pay's gateway integration patterns. |
|
||||
|
||||
---
|
||||
|
||||
## 3. Supported Platforms
|
||||
|
||||
| Platform | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| **Android** | GA | Native APK / App Bundle distribution via Google Play. |
|
||||
| **iOS** | GA | Native IPA distribution via the App Store. |
|
||||
| **Web** | GA | Progressive Web App (PWA) served via Nginx. Works on all modern browsers (Chrome, Firefox, Safari, Edge). |
|
||||
| **Linux / macOS / Windows** | Beta | Desktop builds available for internal/admin use. |
|
||||
|
||||
---
|
||||
|
||||
## 4. Core Feature Set
|
||||
|
||||
### 4.1 Onboarding & Authentication
|
||||
|
||||
- **Phone number sign-in:** Users enter their mobile number, receive an OTP verification code, and authenticate.
|
||||
- **Google Sign-In:** OAuth-based authentication for web users (desktop and mobile web).
|
||||
- **Persisted sessions:** Tokens are stored securely on-device so users remain signed in across cold starts.
|
||||
- **Splash animation:** A branded loading animation plays on cold start before the app routes to the appropriate screen.
|
||||
|
||||
### 4.2 Workspaces
|
||||
|
||||
- After authentication, users are presented with a workspace selector if they belong to multiple organisations.
|
||||
- A workspace bundles a user's billing accounts, transaction history, and groups under one organisational context.
|
||||
- Single-workspace users are auto-routed directly to the home screen.
|
||||
- Guest (unauthenticated) users receive a transient workspace UUID so they can still make one-off payments.
|
||||
|
||||
### 4.3 Home Dashboard
|
||||
|
||||
- **Account balance widget:** Displays the current balance for the authenticated user's workspace. Includes a currency selector (USD, ZWG, etc.).
|
||||
- **Bill provider directory:** A dynamic, API-driven grid of available billers (Econet, NetOne, ZESA, Telecel, etc.). Each card shows the provider's logo, name, and description.
|
||||
- **Recent activity feed:** A chronological list of the user's most recent transactions with status chips (Success / Pending / Failed). Each transaction includes a **Repeat** action that pre-fills the payment flow with the same details.
|
||||
- **Pull-to-refresh:** Manually reload balances, providers, and transactions.
|
||||
- **Responsive layout:** On mobile the UI uses a full-bleed SliverAppBar and bottom NavigationBar; on tablets and desktop it uses a side NavigationRail.
|
||||
|
||||
### 4.4 Payment Flow
|
||||
|
||||
The payment flow is a multi-step wizard with the following stages:
|
||||
|
||||
| Step | Screen | Description |
|
||||
|------|--------|-------------|
|
||||
| 1 | **Home → Provider Select** | User taps a bill provider (e.g. Econet, ZESA). |
|
||||
| 2 | **Recipients** | Choose a saved contact, enter a phone number manually, or pick from the device's native contacts. |
|
||||
| 3 | **Product Selection** (conditional) | If the provider offers multiple products (e.g. airtime bundles, electricity tokens), the user picks the specific product and amount. |
|
||||
| 4 | **Pay** | Enter payment amount (when not fixed by product), select the payment processor (EcoCash / MPGS / Wallet), enter the debit phone number, and optionally record the recipient's name and email for a receipt. |
|
||||
| 5 | **Confirm** | Review all transaction details before submission. The user sees a line-item breakdown: amount, charge, gateway charge, tax, and total. |
|
||||
| 6 | **Gateway** | The app opens the payment processor's checkout page (hosted WebView for Mastercard, or redirect for mobile wallets). The user completes payment authorisation. |
|
||||
| 7 | **Polling** | The app polls the backend for the transaction result for up to 30 attempts (~90 seconds). Progress is indicated by a step counter and status text. |
|
||||
| 8 | **Integration** | On success, the backend posts the payment to the billing provider (e.g. credits the electricity meter). A loading indicator shows this progress. |
|
||||
| 9 | **Receipt** | A full transaction receipt is displayed with share capabilities (image export via `share_plus`). The receipt includes transaction ID, date, amount, charges, provider, and status. |
|
||||
|
||||
**Error handling at every step:** Network errors, failed payments, and integration failures are surfaced with human-readable messages and a **Retry** button. Users can cancel polling at any time.
|
||||
|
||||
### 4.5 Payment Processors
|
||||
|
||||
| Processor | Label | Description |
|
||||
|-----------|-------|-------------|
|
||||
| **EcoCash** | `ECOCASH` | Zimbabwe's mobile money wallet. Authorisation via USSD push or in-app prompt. |
|
||||
| **Mastercard Payment Gateway Services** | `MPGS` | Card payments (Visa, Mastercard). Hosted checkout page in a WebView. |
|
||||
| **Wallet** | `WALLET` | In-app wallet balance (available only to authenticated users with a funded workspace wallet). |
|
||||
|
||||
### 4.6 Transaction History
|
||||
|
||||
- Full, searchable history of all past transactions for the current workspace.
|
||||
- Each transaction item displays: provider icon, provider name, amount, date (relative time), and a coloured status chip.
|
||||
- **Repeat transaction:** One-tap repeat that pre-fills the payment flow with the same provider, product, amount, and payment processor.
|
||||
- **Receipt view:** Tap any historical transaction to view its full receipt.
|
||||
|
||||
### 4.7 Groups & Batch Payments
|
||||
|
||||
Groups allow users to organise recipients and send batch payments efficiently.
|
||||
|
||||
- **Group creation:**
|
||||
- **Manual form:** Enter a group name, description, and add recipients one at a time.
|
||||
- **CSV file import:** Upload a CSV file containing recipient names, phone numbers, amounts, and email addresses. A sample CSV template is provided.
|
||||
- **Group management:** View all groups, search by name, select multiple groups for bulk deletion.
|
||||
- **Batch creation:** Within a group, create a payment batch that processes all recipients. Each batch is a discrete payment run with its own status.
|
||||
- **Batch detail:** View the status of each item (recipient) in a batch. Individual batch items can be inspected for success/failure details.
|
||||
|
||||
### 4.8 Users & Permissions
|
||||
|
||||
- Authenticated workspace admins can view and manage the list of users associated with their workspace (accessible from the **More** menu).
|
||||
- User listing is available at `/users` and is protected behind an auth guard.
|
||||
|
||||
### 4.9 Uptime & Service Status
|
||||
|
||||
- A real-time service status dashboard showing the operational state of every integrated bill provider and payment processor.
|
||||
- Each service displays:
|
||||
- Service name and icon (EcoCash, Econet, ZESA, etc.)
|
||||
- Up/Down status indicator (green/red chip)
|
||||
- Last-checked timestamp (relative time)
|
||||
- Summary bar shows total counts of Up vs Down services.
|
||||
- Data is fetched on-screen load and supports pull-to-refresh.
|
||||
|
||||
### 4.10 Profile & Account Management
|
||||
|
||||
- **Profile:** View account details (accessible from `/profile`).
|
||||
- **Change Workspace:** Switch to a different workspace without logging out.
|
||||
- **Logout:** Clears local session and returns to the landing screen.
|
||||
- **Account Deletion:** GDPR/Play Store compliant account deletion request flow at `/delete-account`.
|
||||
|
||||
### 4.11 Contact & Support
|
||||
|
||||
- Dedicated contact screen accessible from the **More** menu.
|
||||
- Footer links in the web navigation rail provide quick access to About, FAQs, Privacy Policy, and Terms of Service on the Velocity Africa website.
|
||||
|
||||
---
|
||||
|
||||
## 5. Architecture & Technology
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| **Framework** | Flutter (Dart 3.9+) |
|
||||
| **State Management** | Provider + ChangeNotifier (with Freezed immutable models) |
|
||||
| **Routing** | GoRouter (declarative, path-based, with auth guards) |
|
||||
| **HTTP Client** | Dio |
|
||||
| **Code Generation** | Freezed + json_serializable + build_runner |
|
||||
| **Local Storage** | SharedPreferences (token, workspace ID, user preferences) |
|
||||
| **Backend** | Velocity Pay API (`payapi.velocityafrica.net/api`) |
|
||||
| **Auth** | OAuth 2.0 (Google) + Phone OTP |
|
||||
| **Payment Gateway** | Mastercard MPGS (hosted checkout via WebView + JavaScript) |
|
||||
| **Push Notifications** | Firebase Cloud Messaging |
|
||||
| **Analytics** | Firebase |
|
||||
| **Deployment** | Docker + Nginx (web), Google Play (Android), App Store (iOS) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Environment Configuration
|
||||
|
||||
Velocity Pay supports compile-time environment switching via `--dart-define` flags:
|
||||
|
||||
| Flag | Values | Description |
|
||||
|------|--------|-------------|
|
||||
| `APP_ENV` | `test`, `live` | Selects the runtime environment. Defaults to `test`. |
|
||||
| `BASE_URL` | Any URL | Overrides the backend API base URL. |
|
||||
| `CLIENTID` | String | Google OAuth web client ID. |
|
||||
| `SIMULATE_PAYMENT_SUCCESS` | `true`, `false` | When `true`, the gateway flow returns a simulated success (development only). |
|
||||
| `GATEWAY_SCRIPT_URL` | URL | Mastercard checkout JavaScript bundle URL. |
|
||||
|
||||
**Pre-release checklist:**
|
||||
- `assets/.env.live` points to the live API.
|
||||
- `SIMULATE_PAYMENT_SUCCESS` is set to `false`.
|
||||
- `GATEWAY_SCRIPT_URL` uses the production Mastercard script.
|
||||
|
||||
---
|
||||
|
||||
## 7. Navigation Structure
|
||||
|
||||
### Authenticated Shell (with nav bar / nav rail)
|
||||
|
||||
```
|
||||
/home → Home dashboard (providers + recent activity)
|
||||
/groups → Group listing
|
||||
/groups/create → Create group (manual form)
|
||||
/groups/create-from-file → Create group (CSV upload)
|
||||
/groups/:groupId → Group detail
|
||||
/groups/:groupId/batches/create → Create batch for group
|
||||
/groups/batches/:batchId → Batch detail
|
||||
/history → Full transaction history
|
||||
/more → More menu (workspace switch, users, uptime, contact, logout)
|
||||
/users → User management
|
||||
/uptime → Service uptime status
|
||||
/profile → User profile
|
||||
/contact → Contact support
|
||||
/workspace → Workspace selector
|
||||
```
|
||||
|
||||
### Payment Flow (no nav in shell — full-screen wizard)
|
||||
|
||||
```
|
||||
/recipients → Recipient selection
|
||||
/make-payment → Payment form (amount, processor)
|
||||
/confirm → Transaction confirmation
|
||||
/gateway → Payment gateway (native WebView)
|
||||
/gateway-web → Payment gateway (web)
|
||||
/gateway-redirect → Gateway redirect handler
|
||||
/poll → Transaction status polling
|
||||
/poll/:id → Poll for specific transaction
|
||||
/integration → Post-payment billing integration
|
||||
/receipt → Transaction receipt
|
||||
/receipt/:transactionId → Receipt for specific transaction
|
||||
```
|
||||
|
||||
### Onboarding (full-screen, no shell)
|
||||
|
||||
```
|
||||
/onboarding/landing → Welcome landing page
|
||||
/onboarding/phone → Phone number entry
|
||||
/onboarding/verify → OTP verification
|
||||
/onboarding/login → Sign-in (Google OAuth for web)
|
||||
/onboarding/complete → Onboarding complete / redirect
|
||||
```
|
||||
|
||||
### Public
|
||||
|
||||
```
|
||||
/splash → Animated splash screen
|
||||
/delete-account → GDPR account deletion request
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Key User Journeys
|
||||
|
||||
### 8.1 First-Time User — Pay Airtime
|
||||
|
||||
1. Opens app → splash animation plays.
|
||||
2. (Guest) Assigned a transient workspace → lands on Home.
|
||||
3. Taps **Econet** from the provider grid.
|
||||
4. Enters recipient's Econet phone number.
|
||||
5. Selects an airtime bundle product (e.g. $5 Airtime).
|
||||
6. Chooses **EcoCash** as the payment processor.
|
||||
7. Enters their own EcoCash number (the debit phone).
|
||||
8. Reviews the confirmation screen: amount, charges, total.
|
||||
9. Confirms → Gateway opens → approves the EcoCash prompt.
|
||||
10. App polls for status → "Success".
|
||||
11. Billing integration updates the airtime → Receipt displayed.
|
||||
12. Taps **Share** to send receipt via WhatsApp.
|
||||
|
||||
### 8.2 Returning User — Repeat Transaction
|
||||
|
||||
1. Opens app → session is persisted → lands on Home.
|
||||
2. Scrolls to **Recent Activity**.
|
||||
3. Finds a past Econet airtime transaction, taps **Repeat**.
|
||||
4. All fields are pre-filled → confirms without re-entering data.
|
||||
5. Completes the gateway flow → Receipt shown.
|
||||
|
||||
### 8.3 Business User — Batch Payment
|
||||
|
||||
1. Signs in with Google → selects their organisation workspace.
|
||||
2. Navigates to **Groups** via the nav bar.
|
||||
3. Taps **+** → **Create from File** → uploads a CSV of 50 recipients.
|
||||
4. Opens the group → taps **Create Batch**.
|
||||
5. Reviews the batch summary (50 items, total amount).
|
||||
6. Confirms → batch is submitted → each item processed individually.
|
||||
7. Monitors batch progress on the batch detail screen.
|
||||
8. All successes / failures are visible per item.
|
||||
|
||||
---
|
||||
|
||||
## 9. Responsive Design & Accessibility
|
||||
|
||||
- **Mobile (< 768px):** Bottom NavigationBar, single-column layout, SliverAppBar with the Velocity logo.
|
||||
- **Tablet / Desktop (≥ 768px):** Side NavigationRail (256px wide, always visible), constrained content width (600px max), footer links with About/FAQs/Privacy/Terms.
|
||||
- All interactive elements meet minimum touch target sizes (48×48dp).
|
||||
- Skeleton loading placeholders are used during data fetches to reduce perceived latency.
|
||||
|
||||
---
|
||||
|
||||
## 10. Security & Compliance
|
||||
|
||||
- All API communication is over HTTPS.
|
||||
- Auth tokens are persisted in platform-secure local storage.
|
||||
- The Google OAuth client ID is a public value — no secrets are embedded in client bundles.
|
||||
- Payment gateway integration uses Mastercard's hosted checkout; Velocity Pay never handles raw card numbers.
|
||||
- GDPR compliance: Users can request account deletion via a dedicated in-app flow.
|
||||
- Environment configuration uses compile-time flags rather than runtime `.env` files, preventing secrets from leaking into web bundles.
|
||||
|
||||
---
|
||||
|
||||
## 11. Upcoming / Roadmap
|
||||
|
||||
| Feature | Status | Target |
|
||||
|---------|--------|--------|
|
||||
| Push notifications for transaction status | Planned | Q2 2026 |
|
||||
| Dark mode support | Planned | Q2 2026 |
|
||||
| Multi-currency wallet top-up | Planned | Q3 2026 |
|
||||
| Desktop app distribution (App Store / Microsoft Store) | Planned | Q3 2026 |
|
||||
| Offline transaction queuing | Under Review | TBD |
|
||||
| ZWL (ZiG) full currency support | In Progress | Q2 2026 |
|
||||
|
||||
---
|
||||
|
||||
## 12. Glossary
|
||||
|
||||
| Term | Definition |
|
||||
|------|-----------|
|
||||
| **Bill Provider** | A utility company or service provider whose bills can be paid via Velocity Pay (e.g. ZESA, Econet). |
|
||||
| **Payment Processor** | The financial rail used to move funds (EcoCash mobile money, Mastercard/Visa card, in-app Wallet). |
|
||||
| **Workspace** | An organisational context that groups billing accounts, transaction history, and recipient groups. |
|
||||
| **Group** | A collection of recipients for batch payment processing. |
|
||||
| **Batch** | A discrete payment run executed against a group of recipients. |
|
||||
| **Gateway** | The Mastercard-hosted payment checkout page where card details are entered. |
|
||||
| **Polling** | The process of repeatedly checking the backend for a transaction's final status after gateway authorisation. |
|
||||
| **Integration** | The backend step that posts a confirmed payment to the bill provider's system (e.g. crediting a meter). |
|
||||
| **MPGS** | Mastercard Payment Gateway Services — the card payment processor. |
|
||||
|
||||
---
|
||||
|
||||
*For technical setup instructions, environment configuration, and build commands, refer to the project's [README.md](../README.md).*
|
||||
@@ -1 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
43
ios/Podfile
Normal file
43
ios/Podfile
Normal file
@@ -0,0 +1,43 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
# platform :ios, '13.0'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
|
||||
|
||||
project 'Runner', {
|
||||
'Debug' => :debug,
|
||||
'Profile' => :release,
|
||||
'Release' => :release,
|
||||
}
|
||||
|
||||
def flutter_root
|
||||
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
|
||||
unless File.exist?(generated_xcode_build_settings_path)
|
||||
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
|
||||
end
|
||||
|
||||
File.foreach(generated_xcode_build_settings_path) do |line|
|
||||
matches = line.match(/FLUTTER_ROOT\=(.*)/)
|
||||
return matches[1].strip if matches
|
||||
end
|
||||
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
|
||||
end
|
||||
|
||||
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
|
||||
|
||||
flutter_ios_podfile_setup
|
||||
|
||||
target 'Runner' do
|
||||
use_frameworks!
|
||||
|
||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||
target 'RunnerTests' do
|
||||
inherit! :search_paths
|
||||
end
|
||||
end
|
||||
|
||||
post_install do |installer|
|
||||
installer.pods_project.targets.each do |target|
|
||||
flutter_additional_ios_build_settings(target)
|
||||
end
|
||||
end
|
||||
@@ -3,11 +3,7 @@ class ApiResponse<T> {
|
||||
final String? error;
|
||||
final int? statusCode;
|
||||
|
||||
const ApiResponse({
|
||||
this.data,
|
||||
this.error,
|
||||
this.statusCode,
|
||||
});
|
||||
const ApiResponse({this.data, this.error, this.statusCode});
|
||||
|
||||
bool get isSuccess => error == null && data != null;
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import 'package:qpay/screens/users/screens/user_list_screen.dart';
|
||||
import 'package:qpay/screens/more/more_screen.dart';
|
||||
import 'package:qpay/screens/uptime/uptime_status_screen.dart';
|
||||
import 'package:qpay/screens/contact/contact_screen.dart';
|
||||
import 'package:qpay/screens/delete_account/delete_account_screen.dart';
|
||||
|
||||
/// Tracks whether the splash animation is complete so that
|
||||
/// GoRouter's redirect can show the splash first, then release
|
||||
@@ -156,6 +157,11 @@ GoRouter buildRouter() {
|
||||
path: '/splash',
|
||||
builder: (context, state) => const SplashScreen(),
|
||||
),
|
||||
// Publicly accessible account deletion screen (Play Store requirement)
|
||||
GoRoute(
|
||||
path: '/delete-account',
|
||||
builder: (context, state) => const DeleteAccountScreen(),
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) {
|
||||
final location = GoRouterState.of(context).uri.toString();
|
||||
|
||||
@@ -7,6 +7,9 @@ import 'package:qpay/screens/accounts/account_provider.dart';
|
||||
import 'package:qpay/screens/confirm/confirm_controller.dart';
|
||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../transactions/transaction_model.dart';
|
||||
|
||||
class ConfirmScreen extends StatefulWidget {
|
||||
const ConfirmScreen({super.key});
|
||||
@@ -122,7 +125,23 @@ class _ConfirmScreenState extends State<ConfirmScreen>
|
||||
.authType ==
|
||||
"WEB") {
|
||||
if (kIsWeb) {
|
||||
context.push('/gateway-web');
|
||||
final txData = TransactionModel.fromJson(response.data!);
|
||||
// VMC is the only method that requires redirect for now
|
||||
if (txData.targetUrl != null) {
|
||||
final proceed = await _showLeavingSiteBottomSheet();
|
||||
if (proceed != true) return;
|
||||
|
||||
if (!mounted) return;
|
||||
await launchUrl(
|
||||
Uri.parse(txData.targetUrl!),
|
||||
mode: LaunchMode
|
||||
.externalApplication, // always open in external browser
|
||||
);
|
||||
}
|
||||
|
||||
// sent user to another tab and navigate to polling page to wait for response
|
||||
if (!mounted) return;
|
||||
context.push('/poll/${txData.id}');
|
||||
} else {
|
||||
context.push('/gateway');
|
||||
}
|
||||
@@ -304,6 +323,136 @@ class _ConfirmScreenState extends State<ConfirmScreen>
|
||||
);
|
||||
}
|
||||
|
||||
/// Shows a bottom sheet informing the user they are about to leave this site.
|
||||
/// Returns `true` if the user chooses to proceed, `null` if they dismiss or cancel.
|
||||
Future<bool?> _showLeavingSiteBottomSheet() async {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
return showModalBottomSheet<bool>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
builder: (sheetContext) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(24),
|
||||
),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 4,
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.orange.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.security_sharp,
|
||||
size: 28,
|
||||
color: Colors.orange,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Center(
|
||||
child: Text(
|
||||
'Secure Payment Stage',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'We are now redirecting you to our secure payment page. If nothing happens after the redirect, please make sure pop-ups are allowed in your browser settings.',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey.shade500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(sheetContext).pop(true);
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
elevation: 0,
|
||||
),
|
||||
child: const Text(
|
||||
'Proceed',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(sheetContext).pop(null);
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Cancel',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -695,7 +844,7 @@ class _ConfirmScreenState extends State<ConfirmScreen>
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'\$${cityWallet!.balance.toStringAsFixed(2)}',
|
||||
'\$${cityWallet.balance.toStringAsFixed(2)}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
|
||||
541
lib/screens/delete_account/delete_account_screen.dart
Normal file
541
lib/screens/delete_account/delete_account_screen.dart
Normal file
@@ -0,0 +1,541 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../models/responsive_policy.dart';
|
||||
|
||||
/// A publicly accessible screen that allows users to request account
|
||||
/// deletion in compliance with Google Play Store Data Deletion
|
||||
/// requirements.
|
||||
///
|
||||
/// This screen:
|
||||
/// - Explains what data is deleted and retained.
|
||||
/// - Provides a clear call-to-action for deletion.
|
||||
/// - Includes a contact fallback for support.
|
||||
class DeleteAccountScreen extends StatelessWidget {
|
||||
const DeleteAccountScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDark
|
||||
? const Color(0xFF0D0D0D)
|
||||
: const Color(0xFFF8F9FA),
|
||||
appBar: AppBar(
|
||||
leading: context.canPop()
|
||||
? IconButton(
|
||||
onPressed: () => context.pop(),
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
color: theme.colorScheme.primary,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
title: Text(
|
||||
'Request Account Deletion',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 18,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
backgroundColor: isDark
|
||||
? const Color(0xFF0D0D0D)
|
||||
: const Color(0xFFF8F9FA),
|
||||
),
|
||||
body: Center(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
|
||||
? ResponsivePolicy.md.toDouble()
|
||||
: constraints.maxWidth;
|
||||
|
||||
return SizedBox(
|
||||
width: maxWidth,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Header / intro card
|
||||
_buildHeaderCard(theme, isDark),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// What happens to your data
|
||||
_buildDataDeletionCard(theme, isDark),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Action card
|
||||
_buildActionCard(theme, isDark, context),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Contact fallback
|
||||
_buildContactCard(theme, isDark),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeaderCard(ThemeData theme, bool isDark) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 24),
|
||||
decoration: _cardDecoration(theme, isDark),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.red.shade50,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.red.shade200, width: 1.5),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.delete_forever_rounded,
|
||||
size: 36,
|
||||
color: Colors.red.shade600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Delete Your Account',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'We\'re sorry to see you go. Please read the information '
|
||||
'below before proceeding with your deletion request.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: isDark ? Colors.white60 : Colors.grey.shade600,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDataDeletionCard(ThemeData theme, bool isDark) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: _cardDecoration(theme, isDark),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline_rounded,
|
||||
size: 22,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'What happens when you delete your account?',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_buildInfoPoint(
|
||||
isDark: isDark,
|
||||
icon: Icons.person_remove_rounded,
|
||||
title: 'Profile & Personal Data',
|
||||
description:
|
||||
'Your profile information, including your name, phone number, '
|
||||
'email address, and profile picture will be permanently erased.',
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_buildInfoPoint(
|
||||
isDark: isDark,
|
||||
icon: Icons.history_rounded,
|
||||
title: 'Transaction History',
|
||||
description:
|
||||
'All transaction records, payment history, and receipts '
|
||||
'associated with your account will be deleted.',
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_buildInfoPoint(
|
||||
isDark: isDark,
|
||||
icon: Icons.group_remove_rounded,
|
||||
title: 'Group & Recipient Data',
|
||||
description:
|
||||
'Any groups you have created and recipient data you have '
|
||||
'stored will be removed from our systems.',
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_buildInfoPoint(
|
||||
isDark: isDark,
|
||||
icon: Icons.schedule_rounded,
|
||||
title: 'Retention Period',
|
||||
description:
|
||||
'Some data may be retained for up to 90 days as required by '
|
||||
'legal and regulatory obligations. After this period, all '
|
||||
'remaining data will be permanently deleted.',
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_buildInfoPoint(
|
||||
isDark: isDark,
|
||||
icon: Icons.undo_rounded,
|
||||
title: 'Irreversible Action',
|
||||
description:
|
||||
'Account deletion is permanent and cannot be undone once '
|
||||
'processed. Please ensure you have exported any data you '
|
||||
'wish to keep before submitting your request.',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoPoint({
|
||||
required bool isDark,
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required String description,
|
||||
}) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Icon(icon, size: 20, color: Colors.red.shade400),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
description,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: isDark ? Colors.white54 : Colors.grey.shade600,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionCard(ThemeData theme, bool isDark, BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: _cardDecoration(theme, isDark),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.send_rounded,
|
||||
size: 22,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'How to Request Deletion',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'To ensure the security of your account, all deletion '
|
||||
'requests must be verified. Please use one of the '
|
||||
'following methods to submit your request.',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: isDark ? Colors.white60 : Colors.grey.shade600,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// Email deletion request
|
||||
_buildActionButton(
|
||||
context: context,
|
||||
isDark: isDark,
|
||||
icon: Icons.email_rounded,
|
||||
iconColor: const Color(0xFFEA4335),
|
||||
title: 'Request via Email',
|
||||
subtitle: 'Send us a deletion request from your registered email',
|
||||
onTap: () {
|
||||
launchUrl(
|
||||
Uri(
|
||||
scheme: 'mailto',
|
||||
path: 'support@velocityafrica.net',
|
||||
queryParameters: {
|
||||
'subject': 'Account Deletion Request',
|
||||
'body':
|
||||
'Please delete my Velocity account. My registered phone '
|
||||
'number is: [enter your phone number]',
|
||||
},
|
||||
),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Processing note
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time_rounded,
|
||||
size: 18,
|
||||
color: isDark ? Colors.white60 : Colors.grey.shade700,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'We process deletion requests within 7 business days. '
|
||||
'You will receive a confirmation email once your '
|
||||
'account has been deleted.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isDark ? Colors.white54 : Colors.grey.shade600,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildActionButton({
|
||||
required BuildContext context,
|
||||
required bool isDark,
|
||||
required IconData icon,
|
||||
required Color iconColor,
|
||||
required String title,
|
||||
required String subtitle,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.03)
|
||||
: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.04)
|
||||
: Colors.grey.shade100,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: iconColor.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Center(child: Icon(icon, color: iconColor, size: 24)),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: isDark ? Colors.white54 : Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: iconColor.withValues(alpha: 0.1),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.arrow_forward_rounded,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContactCard(ThemeData theme, bool isDark) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: _cardDecoration(theme, isDark),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.help_outline_rounded,
|
||||
size: 22,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Need Help?',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'If you have any questions about the deletion process or '
|
||||
'would like assistance, our support team is here to help.',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
color: isDark ? Colors.white60 : Colors.grey.shade600,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.phone_rounded,
|
||||
size: 18,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'+263 78 777 0295',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.email_outlined,
|
||||
size: 18,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'support@velocityafrica.net',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
BoxDecoration _cardDecoration(ThemeData theme, bool isDark) {
|
||||
return BoxDecoration(
|
||||
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.06)
|
||||
: Colors.grey.shade200,
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: isDark
|
||||
? Colors.black.withValues(alpha: 0.3)
|
||||
: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,13 @@ import 'package:provider/provider.dart';
|
||||
|
||||
class GatewayModel {
|
||||
bool isLoading = false;
|
||||
String status = '';
|
||||
String status = 'PENDING';
|
||||
String pollStatus = 'PENDING';
|
||||
String? errorMessage;
|
||||
bool isCancelled = false;
|
||||
int count = 0;
|
||||
int pollAttempt = 0;
|
||||
int pollMaxAttempts = 30;
|
||||
}
|
||||
|
||||
class GatewayController extends ChangeNotifier {
|
||||
@@ -84,19 +87,26 @@ class GatewayController extends ChangeNotifier {
|
||||
|
||||
Future<ApiResponse<Map<String, dynamic>>> pollTransaction(String uid) async {
|
||||
// poll up to 15 times (~45 seconds at 3-second intervals)
|
||||
int maxAttempts = 15;
|
||||
int attempt = 0;
|
||||
|
||||
while (!model.isCancelled && attempt < maxAttempts) {
|
||||
model.pollAttempt = 0;
|
||||
notifyListeners();
|
||||
|
||||
while (!model.isCancelled && attempt < model.pollMaxAttempts) {
|
||||
attempt++;
|
||||
model.pollAttempt = attempt;
|
||||
notifyListeners();
|
||||
|
||||
ApiResponse<Map<String, dynamic>> result = await poll(uid);
|
||||
|
||||
// Only stop on success or failure
|
||||
if (result.isSuccess) {
|
||||
String status = model.status;
|
||||
// If status is SUCCESS, we're done
|
||||
if (status == 'SUCCESS') {
|
||||
if (model.status == 'SUCCESS') {
|
||||
model.isCancelled = true;
|
||||
model.pollStatus = model.status;
|
||||
finishLoading();
|
||||
notifyListeners();
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -107,18 +117,15 @@ class GatewayController extends ChangeNotifier {
|
||||
} else {
|
||||
await Future.delayed(const Duration(seconds: 3));
|
||||
if (model.isCancelled) break;
|
||||
|
||||
// Network error — show failure UI and stop
|
||||
model.isCancelled = true;
|
||||
model.status = 'FAILED';
|
||||
model.errorMessage = result.error;
|
||||
return result;
|
||||
model.status = 'PENDING';
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// Timed out after max attempts
|
||||
model.isCancelled = true;
|
||||
model.status = 'FAILED';
|
||||
model.pollStatus = 'FAILED';
|
||||
model.errorMessage = 'We failed to check your transaction status';
|
||||
finishLoading();
|
||||
notifyListeners();
|
||||
|
||||
@@ -57,6 +57,8 @@ class GroupBatchItem {
|
||||
final String? status;
|
||||
final String? transactionId;
|
||||
final String? errorMessage;
|
||||
final String? confirmError;
|
||||
final String? integrationError;
|
||||
final String? createdAt;
|
||||
final String? billName;
|
||||
final String? billProductName;
|
||||
@@ -80,6 +82,8 @@ class GroupBatchItem {
|
||||
this.providerLabel,
|
||||
this.providerImage,
|
||||
this.creditAddress,
|
||||
this.confirmError,
|
||||
this.integrationError,
|
||||
});
|
||||
|
||||
factory GroupBatchItem.fromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -62,6 +62,8 @@ GroupBatchItem _$GroupBatchItemFromJson(Map<String, dynamic> json) =>
|
||||
providerLabel: json['providerLabel'] as String?,
|
||||
providerImage: json['providerImage'] as String?,
|
||||
creditAddress: json['creditAddress'] as String?,
|
||||
confirmError: json['confirmError'] as String?,
|
||||
integrationError: json['integrationError'] as String?,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$GroupBatchItemToJson(GroupBatchItem instance) =>
|
||||
@@ -75,6 +77,8 @@ Map<String, dynamic> _$GroupBatchItemToJson(GroupBatchItem instance) =>
|
||||
'status': instance.status,
|
||||
'transactionId': instance.transactionId,
|
||||
'errorMessage': instance.errorMessage,
|
||||
'confirmError': instance.confirmError,
|
||||
'integrationError': instance.integrationError,
|
||||
'createdAt': instance.createdAt,
|
||||
'billName': instance.billName,
|
||||
'billProductName': instance.billProductName,
|
||||
|
||||
@@ -2994,6 +2994,8 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||
],
|
||||
),
|
||||
Text(item.creditAddress ?? ''),
|
||||
Text(item.confirmError ?? ''),
|
||||
Text(item.integrationError ?? ''),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -97,6 +97,13 @@ class _MoreScreenState extends State<MoreScreen> {
|
||||
subtitle: 'Get in touch with our team',
|
||||
onTap: () => context.push('/contact'),
|
||||
),
|
||||
_NavOptionTile(
|
||||
icon: Icons.delete_forever_outlined,
|
||||
title: 'Request Account Deletion',
|
||||
subtitle: 'Permanently delete your account and data',
|
||||
onTap: () => context.push('/delete-account'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_NavOptionTile(
|
||||
icon: Icons.logout,
|
||||
title: 'Logout',
|
||||
|
||||
@@ -185,8 +185,15 @@ class PayController extends ChangeNotifier {
|
||||
model.paymentProcessors = getFakePaymentProcessors();
|
||||
notifyListeners();
|
||||
|
||||
var labels = ['ECOCASH', 'MPGS'];
|
||||
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
if (prefs.get('token') != null) {
|
||||
labels.add('WALLET');
|
||||
}
|
||||
|
||||
final response = await http.get(
|
||||
'/public/payment-processors?currency=$currency',
|
||||
'/public/payment-processors?currency=$currency&labelIn=${labels.join(',')}',
|
||||
);
|
||||
final List<dynamic> content = response is List
|
||||
? response
|
||||
|
||||
@@ -304,7 +304,7 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
|
||||
.account_balance_wallet_outlined,
|
||||
label: "City Wallet Balance",
|
||||
value:
|
||||
'\$${cityWallet!.balance.toStringAsFixed(2)}',
|
||||
'\$${cityWallet.balance.toStringAsFixed(2)}',
|
||||
valueStyle: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
|
||||
@@ -78,7 +78,7 @@ class _PollScreenState extends State<PollScreen> {
|
||||
body: ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, child) {
|
||||
if (controller.model.status == 'SUCCESS') {
|
||||
if (controller.model.pollStatus == 'SUCCESS') {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.go('/integration');
|
||||
});
|
||||
@@ -90,13 +90,17 @@ class _PollScreenState extends State<PollScreen> {
|
||||
subtitle:
|
||||
'We\'re checking with our gateway if your transaction was '
|
||||
'successful. This won\'t take long.',
|
||||
status: controller.model.status,
|
||||
status: controller.model.pollStatus,
|
||||
isLoading: controller.model.isLoading,
|
||||
errorMessage:
|
||||
controller.model.errorMessage ??
|
||||
'We failed to check your transaction status',
|
||||
pollAttempt: controller.model.pollAttempt,
|
||||
pollMaxAttempts: controller.model.pollMaxAttempts,
|
||||
onRetry: () {
|
||||
controller.model.isCancelled = false;
|
||||
controller.model.pollStatus = 'PENDING';
|
||||
controller.model.pollAttempt = 0;
|
||||
_startPolling();
|
||||
},
|
||||
);
|
||||
|
||||
@@ -13,8 +13,6 @@ import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:qpay/screens/recipient/recipients_controller.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../onboarding/auth_provider.dart';
|
||||
|
||||
class RecipientsScreen extends StatefulWidget {
|
||||
const RecipientsScreen({super.key});
|
||||
|
||||
@@ -87,6 +85,26 @@ class _RecipientsScreenState extends State<RecipientsScreen>
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
_applyRecipient(Recipient recipient) {
|
||||
String account = recipient.account ?? '';
|
||||
if (!transactionController.model.selectedProvider!.requiresAccount) {
|
||||
account = recipient.phoneNumber ?? '';
|
||||
}
|
||||
transactionController.model.formData = transactionController.model.formData
|
||||
.copyWith(
|
||||
creditAccount: account,
|
||||
creditName: recipient.name ?? '',
|
||||
creditPhone: recipient.phoneNumber ?? '',
|
||||
creditEmail: recipient.email ?? '',
|
||||
providerLabel:
|
||||
transactionController.model.formData.providerLabel.isEmpty
|
||||
? recipient.latestProviderLabel ?? ''
|
||||
: transactionController.model.formData.providerLabel,
|
||||
);
|
||||
if (!mounted) return;
|
||||
context.push("/make-payment");
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -330,32 +348,7 @@ class _RecipientsScreenState extends State<RecipientsScreen>
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
String account = recipient.account ?? '';
|
||||
if (!transactionController
|
||||
.model
|
||||
.selectedProvider!
|
||||
.requiresAccount) {
|
||||
account = recipient.phoneNumber ?? '';
|
||||
}
|
||||
transactionController
|
||||
.model
|
||||
.formData = transactionController.model.formData.copyWith(
|
||||
creditAccount: account,
|
||||
creditName: recipient.name ?? '',
|
||||
creditPhone: recipient.phoneNumber ?? '',
|
||||
creditEmail: recipient.email ?? '',
|
||||
providerLabel:
|
||||
transactionController
|
||||
.model
|
||||
.formData
|
||||
.providerLabel
|
||||
.isEmpty
|
||||
? recipient.latestProviderLabel ?? ''
|
||||
: transactionController.model.formData.providerLabel,
|
||||
);
|
||||
context.push("/make-payment");
|
||||
},
|
||||
onTap: () => _applyRecipient(recipient),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:qpay/http/http.dart';
|
||||
import 'package:qpay/browser.dart';
|
||||
|
||||
const appBuildNumber = 5;
|
||||
const appBuildNumber = 6;
|
||||
|
||||
/// Describes the result of the build-number check performed during splash.
|
||||
enum SplashCheckStatus { checking, noUpdate, updateAvailable }
|
||||
|
||||
@@ -247,7 +247,6 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
/// user can install the latest version. No cache clearing needed.
|
||||
void _showMobileUpdateSheet(SplashProvider provider, String version) {
|
||||
final message =
|
||||
provider.updatePayload?['message']?.toString() ??
|
||||
'A new version of the app is available. Please download the latest version from the store.';
|
||||
final downloadUrl = provider.downloadUrl;
|
||||
|
||||
|
||||
@@ -95,6 +95,7 @@ class TransactionModel {
|
||||
final String? creditEmail;
|
||||
final String? productUid;
|
||||
final String? targetUrl;
|
||||
final String? redirectUrl;
|
||||
final List<Map<String, dynamic>>? additionalData;
|
||||
|
||||
const TransactionModel({
|
||||
@@ -145,6 +146,7 @@ class TransactionModel {
|
||||
this.creditEmail,
|
||||
this.productUid,
|
||||
this.targetUrl,
|
||||
this.redirectUrl,
|
||||
this.additionalData,
|
||||
});
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ TransactionModel _$TransactionModelFromJson(Map<String, dynamic> json) =>
|
||||
creditEmail: json['creditEmail'] as String?,
|
||||
productUid: json['productUid'] as String?,
|
||||
targetUrl: json['targetUrl'] as String?,
|
||||
redirectUrl: json['redirectUrl'] as String?,
|
||||
additionalData: const AdditionalDataJsonConverter().fromJson(
|
||||
json['additionalData'] as List?,
|
||||
),
|
||||
@@ -120,6 +121,7 @@ Map<String, dynamic> _$TransactionModelToJson(
|
||||
'creditEmail': instance.creditEmail,
|
||||
'productUid': instance.productUid,
|
||||
'targetUrl': instance.targetUrl,
|
||||
'redirectUrl': instance.redirectUrl,
|
||||
'additionalData': const AdditionalDataJsonConverter().toJson(
|
||||
instance.additionalData,
|
||||
),
|
||||
|
||||
@@ -59,9 +59,9 @@ class _WorkspaceScreenState extends State<WorkspaceScreen>
|
||||
_loadWorkspaces();
|
||||
} else {
|
||||
// if not logged in then create temp workspace
|
||||
SharedPreferences.getInstance().then((prefs) {
|
||||
prefs.setString('workspaceId', const Uuid().v4());
|
||||
});
|
||||
if (prefs.getString("workspaceId") == null) {
|
||||
await prefs.setString("workspaceId", const Uuid().v4());
|
||||
}
|
||||
_navigateToHome();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ class StepLoadingWidget extends StatefulWidget {
|
||||
final bool isLoading;
|
||||
final String? errorMessage;
|
||||
final VoidCallback? onRetry;
|
||||
final int pollAttempt;
|
||||
final int pollMaxAttempts;
|
||||
|
||||
const StepLoadingWidget({
|
||||
super.key,
|
||||
@@ -21,6 +23,8 @@ class StepLoadingWidget extends StatefulWidget {
|
||||
required this.isLoading,
|
||||
this.errorMessage,
|
||||
this.onRetry,
|
||||
this.pollAttempt = 0,
|
||||
this.pollMaxAttempts = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -144,7 +148,24 @@ class _StepLoadingWidgetState extends State<StepLoadingWidget>
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
const SizedBox(height: 50),
|
||||
// Poll attempt counter
|
||||
if (widget.pollMaxAttempts > 0 &&
|
||||
widget.status != 'SUCCESS' &&
|
||||
widget.status != 'FAILED')
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
'Attempt ${widget.pollAttempt} of ${widget.pollMaxAttempts}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey.shade500,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 36),
|
||||
|
||||
// Error state with retry
|
||||
if (widget.status == 'FAILED')
|
||||
|
||||
@@ -13,7 +13,7 @@ http {
|
||||
index index.html;
|
||||
|
||||
# Security headers
|
||||
add_header Content-Security-Policy "connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||
add_header Content-Security-Policy "object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||
|
||||
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||
# In Windows, build-name is used as the major, minor, and patch parts
|
||||
# of the product and file versions while build-number is used as the build suffix.
|
||||
version: 1.0.0+1
|
||||
version: 1.0.2+4
|
||||
|
||||
environment:
|
||||
sdk: ^3.9.2
|
||||
|
||||
Reference in New Issue
Block a user