Compare commits

...

10 Commits

Author SHA1 Message Date
Prince
2bcee59fd7 added wallet functionality 2026-06-11 23:48:26 +02:00
Prince
ad79bf2af8 adding CityWallet functionality 2026-06-11 16:33:22 +02:00
b10d233428 fixing landing page 2026-06-08 10:17:22 +02:00
0a267bdbb8 fixing landing page 2026-06-08 10:13:27 +02:00
fc616d6316 completed group batch feature 2026-06-08 09:17:36 +02:00
2dd790fe6e progress on batches 2026-06-05 20:25:20 +02:00
eab2368331 usability fixes 2026-06-01 21:04:06 +02:00
1d8ab2088c usability fixes 2026-06-01 20:49:53 +02:00
7b5b962fe6 minor fixes 2026-06-01 14:54:10 +02:00
59c96d889a minor login fixes 2026-06-01 14:50:02 +02:00
77 changed files with 14899 additions and 2521 deletions

View File

@@ -42,6 +42,10 @@ FROM nginx:1.25.2-alpine
# copy the info of the builded web app to nginx # copy the info of the builded web app to nginx
COPY --from=build-env /app/build/web /usr/share/nginx/html COPY --from=build-env /app/build/web /usr/share/nginx/html
# Remove default nginx configuration and copy custom one
RUN rm /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/nginx.conf
# Expose and run nginx # Expose and run nginx
EXPOSE 80 EXPOSE 80
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

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

4225
QPay.postman_collection.json Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
BASE_URL=https://peakapi.qantra.co.zw/api BASE_URL=https://api.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

View File

@@ -1,4 +1,4 @@
BASE_URL=https://peakapi.qantra.co.zw/api BASE_URL=https://api.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

BIN
assets/city.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
assets/complete.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
assets/signup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

83
group-batches.md Normal file
View File

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

View File

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

View File

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

87
lib/auth_state.dart Normal file
View File

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

View File

@@ -1,6 +1,7 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_dotenv/flutter_dotenv.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';
var logger = Logger(printer: PrettyPrinter()); var logger = Logger(printer: PrettyPrinter());
@@ -24,9 +25,22 @@ class Http {
); );
} }
Future<Map<String, String>> getHeaders() async {
Map<String, String> headers = {'Content-Type': 'application/json'};
var prefs = await SharedPreferences.getInstance();
String token = prefs.getString("token") ?? "";
headers['Authorization'] = 'Bearer $token';
return headers;
}
Future<dynamic> get(String url) async { Future<dynamic> get(String url) async {
Map<String, String> headers = await getHeaders();
Response response; Response response;
response = await dio.get(baseUrl + url); response = await dio.get(baseUrl + url, options: Options(headers: headers));
logger.i(response.data.toString()); logger.i(response.data.toString());
return response.data; return response.data;
} }
@@ -43,4 +57,29 @@ class Http {
logger.i(response.data.toString()); logger.i(response.data.toString());
return response.data; return response.data;
} }
Future<dynamic> put(String url, dynamic data) async {
Map<String, String> headers = await getHeaders();
Response response;
response = await dio.put(
baseUrl + url,
data: data,
options: Options(headers: headers),
);
logger.i(response.data.toString());
return response.data;
}
Future<dynamic> delete(String url) async {
Map<String, String> headers = await getHeaders();
Response response;
response = await dio.delete(
baseUrl + url,
options: Options(headers: headers),
);
logger.i(response.data.toString());
return response.data;
}
} }

View File

@@ -1,30 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_web_plugins/flutter_web_plugins.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:go_router/go_router.dart'; import 'package:qpay/auth_state.dart';
import 'package:qpay/navbar.dart'; import 'package:qpay/routes.dart';
import 'package:qpay/screens/confirm/confirm_screen.dart';
import 'package:qpay/screens/gateway/gateway_redirect.dart';
import 'package:qpay/screens/gateway/gateway_screen.dart';
import 'package:qpay/screens/gateway/gateway_web_screen.dart';
import 'package:qpay/screens/home/home_screen.dart';
import 'package:qpay/screens/history/history_screen.dart';
import 'package:qpay/screens/integration/integration_screen.dart';
import 'package:qpay/screens/onboarding/complete_screen.dart';
import 'package:qpay/screens/onboarding/landing/landing_screen.dart';
import 'package:qpay/screens/onboarding/login/login_screen.dart';
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
import 'package:qpay/screens/onboarding/password/password_screen.dart';
import 'package:qpay/screens/onboarding/phone/phone_screen.dart';
import 'package:qpay/screens/onboarding/register/register_screen.dart';
import 'package:qpay/screens/onboarding/verify/verify_screen.dart';
import 'package:qpay/screens/pay/pay_screen.dart';
import 'package:qpay/screens/poll/poll_screen.dart';
import 'package:qpay/screens/splash_screen.dart';
import 'package:qpay/screens/receipt/receipt_screen.dart';
import 'package:qpay/screens/recipient/recipients_screen.dart';
import 'package:qpay/screens/transaction_controller.dart'; import 'package:qpay/screens/transaction_controller.dart';
import 'package:qpay/screens/profile_screen.dart'; import 'package:qpay/screens/onboarding/onboarding_controller.dart';
import 'package:qpay/theme/app_theme.dart'; import 'package:qpay/theme/app_theme.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
@@ -44,42 +24,19 @@ String resolveEnvFile(String env) {
} }
} }
/// Tracks whether the splash animation is complete so that
/// GoRouter's redirect can show the splash first, then release
/// navigation to the originally intended URL.
class SplashState extends ChangeNotifier {
bool _complete = false;
/// The route the user originally intended to visit before being
/// redirected to /splash. Set by the redirect callback on first
/// invocation.
String? _intendedRoute;
bool get complete => _complete;
String? get intendedRoute => _intendedRoute;
/// Called by the redirect to store the intended destination and
/// return the splash route.
String? guard(String attemptedRoute) {
if (_complete) return null;
// Avoid saving the splash route itself.
if (attemptedRoute == '/splash') return null;
_intendedRoute = attemptedRoute;
return '/splash';
}
void finish() {
_complete = true;
notifyListeners();
}
}
final SplashState splashState = SplashState();
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
usePathUrlStrategy(); usePathUrlStrategy();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
await dotenv.load(fileName: resolveEnvFile(appEnv)); await dotenv.load(fileName: resolveEnvFile(appEnv));
// Read the persisted auth token before the first frame so the router
// knows whether the user is already signed in on cold start. Without
// this we would briefly treat logged-in users as guests and bounce
// them to the sign-in screen until the first refresh kicked in.
await authState.initialize();
runApp( runApp(
MultiProvider( MultiProvider(
providers: [ providers: [
@@ -110,150 +67,7 @@ class _MyAppState extends State<MyApp> {
theme: AppTheme.lightTheme, theme: AppTheme.lightTheme,
themeMode: ThemeMode.light, themeMode: ThemeMode.light,
debugShowCheckedModeBanner: false, debugShowCheckedModeBanner: false,
routerConfig: GoRouter( routerConfig: buildRouter(),
initialLocation: '/',
refreshListenable: splashState,
errorBuilder: (context, state) => Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Page not found', style: TextStyle(fontSize: 20)),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => context.go('/'),
child: const Text('Go Home'),
),
],
),
),
),
redirect: (context, state) {
// Before splash is complete: redirect everything to /splash,
// but save the original intended destination.
final guardResult = splashState.guard(state.uri.path);
if (guardResult != null) return guardResult;
// Once splash is complete: if we're still on /splash, navigate
// to the intended destination (or home as fallback).
if (splashState.complete && state.uri.path == '/splash') {
return splashState.intendedRoute ?? '/';
}
return null;
},
routes: [
// Splash route sits outside the ShellRoute so it renders
// without the bottom nav bar or side rail.
GoRoute(
path: '/splash',
builder: (context, state) => const SplashScreen(),
),
ShellRoute(
builder: (context, state, child) {
final location = GoRouterState.of(context).uri.toString();
if (location.startsWith('/onboarding/')) {
return child;
}
return ScaffoldWithNavBar(child: child);
},
routes: [
GoRoute(
path: '/',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/home',
builder: (context, state) => const HomeScreen(),
),
GoRoute(
path: '/make-payment',
builder: (context, state) => const PayScreen(),
),
GoRoute(
path: '/confirm',
builder: (context, state) => const ConfirmScreen(),
),
GoRoute(
path: '/gateway',
builder: (context, state) => const GatewayScreen(),
),
GoRoute(
path: '/gateway-web',
builder: (context, state) => const GatewayWebScreen(),
),
GoRoute(
path: '/gateway-redirect',
builder: (context, state) => const GatewayRedirectScreen(),
),
GoRoute(
path: '/poll/:id',
builder: (context, state) => PollScreen(
transactionId: state.pathParameters['id'],
),
),
GoRoute(
path: '/poll',
builder: (context, state) => const PollScreen(),
),
GoRoute(
path: '/receipt',
builder: (context, state) => const ReceiptScreen(),
),
GoRoute(
path: '/receipt/:transactionId',
builder: (context, state) => ReceiptScreen(
transactionId: state.pathParameters['transactionId'],
),
),
GoRoute(
path: '/integration',
builder: (context, state) => const IntegrationScreen(),
),
GoRoute(
path: '/recipients',
builder: (context, state) => const RecipientsScreen(),
),
GoRoute(
path: '/history',
builder: (context, state) => const HistoryScreen(),
),
GoRoute(
path: '/profile',
builder: (context, state) => const ProfileScreen(),
),
GoRoute(
path: '/onboarding/landing',
builder: (context, state) => const LandingScreen(),
),
GoRoute(
path: '/onboarding/register',
builder: (context, state) => const RegisterScreen(),
),
GoRoute(
path: '/onboarding/phone',
builder: (context, state) => const PhoneScreen(),
),
GoRoute(
path: '/onboarding/verify',
builder: (context, state) => const VerifyScreen(),
),
GoRoute(
path: '/onboarding/password',
builder: (context, state) => const PasswordScreen(),
),
GoRoute(
path: '/onboarding/login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/onboarding/complete',
builder: (context, state) => const CompleteScreen(),
),
],
),
],
),
); );
} }
} }

View File

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

282
lib/routes.dart Normal file
View File

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

View File

@@ -0,0 +1,101 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:qpay/screens/accounts/models/account_model.dart';
import 'package:qpay/screens/accounts/models/statement_model.dart';
class AccountProvider extends ChangeNotifier {
final Http http = Http();
AccountProvider();
AccountModel? _account;
StatementModel? _statement;
bool _isLoadingAccount = false;
bool _isLoadingStatement = false;
String? _accountError;
String? _statementError;
AccountModel? get account => _account;
StatementModel? get statement => _statement;
bool get isLoadingAccount => _isLoadingAccount;
bool get isLoadingStatement => _isLoadingStatement;
String? get accountError => _accountError;
String? get statementError => _statementError;
Future<ApiResponse<AccountModel>> fetchAccount(String phoneNumber) async {
_isLoadingAccount = true;
_accountError = null;
notifyListeners();
try {
Map<String, dynamic> response = await http.get(
'/accounts/phone/$phoneNumber',
);
_account = AccountModel.fromJson(response);
_isLoadingAccount = false;
notifyListeners();
return ApiResponse.success(_account!);
} on DioException catch (e, s) {
logger.e(s);
_isLoadingAccount = false;
_accountError = 'Failed to fetch account. Please try again.';
notifyListeners();
return ApiResponse.failure(_accountError!);
} catch (e, s) {
logger.e(s);
_isLoadingAccount = false;
_accountError = 'An unexpected error occurred. Please try again.';
notifyListeners();
return ApiResponse.failure(_accountError!);
}
}
Future<ApiResponse<StatementModel>> fetchStatement({
required String phoneNumber,
required String startDate,
required String endDate,
}) async {
_isLoadingStatement = true;
_statementError = null;
notifyListeners();
try {
Map<String, dynamic> response = await http.get(
'/accounts/phone/$phoneNumber/statement'
'?startDate=$startDate&endDate=$endDate',
);
_statement = StatementModel.fromJson(response);
_isLoadingStatement = false;
notifyListeners();
return ApiResponse.success(_statement!);
} on DioException catch (e, s) {
logger.e(s);
_isLoadingStatement = false;
_statementError = 'Failed to fetch statement. Please try again.';
notifyListeners();
return ApiResponse.failure(_statementError!);
} catch (e, s) {
logger.e(s);
_isLoadingStatement = false;
_statementError = 'An unexpected error occurred. Please try again.';
notifyListeners();
return ApiResponse.failure(_statementError!);
}
}
void clearAccount() {
_account = null;
_accountError = null;
notifyListeners();
}
void clearStatement() {
_statement = null;
_statementError = null;
notifyListeners();
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,390 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:qpay/screens/accounts/account_provider.dart';
import 'package:qpay/screens/accounts/models/account_model.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AccountBalanceWidget extends StatefulWidget {
const AccountBalanceWidget({super.key});
@override
State<AccountBalanceWidget> createState() => _AccountBalanceWidgetState();
}
class _AccountBalanceWidgetState extends State<AccountBalanceWidget>
with SingleTickerProviderStateMixin {
late AccountProvider _accountProvider;
AccountModel? _usdAccount;
AccountModel? _zwgAccount;
bool _isLoadingUsd = false;
bool _isLoadingZwG = false;
String? _usdError;
String? _zwgError;
String? _phoneNumber;
late AnimationController _pulseController;
@override
void initState() {
super.initState();
_pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
_accountProvider = AccountProvider();
_loadBalances();
}
@override
void dispose() {
_pulseController.dispose();
_accountProvider.dispose();
super.dispose();
}
Future<void> _loadBalances() async {
final prefs = await SharedPreferences.getInstance();
_phoneNumber = prefs.getString('phone');
if (_phoneNumber == null || _phoneNumber!.isEmpty) return;
setState(() {
_isLoadingUsd = true;
_isLoadingZwG = true;
});
// Fetch USD balance
final usdResult = await _accountProvider.fetchAccount(
'USD$_phoneNumber',
);
if (mounted) {
setState(() {
_isLoadingUsd = false;
if (usdResult.isSuccess && usdResult.data != null) {
_usdAccount = usdResult.data;
_usdError = null;
} else {
_usdError = usdResult.error;
}
});
}
// Fetch ZWG balance
final zwgResult = await _accountProvider.fetchAccount(
'ZWG$_phoneNumber',
);
if (mounted) {
setState(() {
_isLoadingZwG = false;
if (zwgResult.isSuccess && zwgResult.data != null) {
_zwgAccount = zwgResult.data;
_zwgError = null;
} else {
_zwgError = zwgResult.error;
}
});
}
if (mounted) {
_pulseController.forward();
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
if (_phoneNumber == null || _phoneNumber!.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSectionLabel(
theme,
isDark,
Icons.account_balance_wallet_rounded,
'Account Balances',
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _buildBalanceCard(
theme: theme,
isDark: isDark,
currencyCode: 'USD',
currencyName: 'US Dollar',
flagAsset: 'united-states.png',
balance: _usdAccount?.balance,
isLoading: _isLoadingUsd,
error: _usdError,
gradientColors: [
const Color(0xFF0D47A1),
const Color(0xFF1976D2),
],
),
),
const SizedBox(width: 12),
Expanded(
child: _buildBalanceCard(
theme: theme,
isDark: isDark,
currencyCode: 'ZWG',
currencyName: 'Zimbabwe Gold',
flagAsset: 'zimbabwe.png',
balance: _zwgAccount?.balance,
isLoading: _isLoadingZwG,
error: _zwgError,
gradientColors: [
const Color(0xFF1B5E20),
const Color(0xFF388E3C),
],
),
),
],
),
const SizedBox(height: 8),
Center(
child: TextButton.icon(
onPressed: _isLoadingUsd || _isLoadingZwG
? null
: () {
_pulseController.reset();
_loadBalances();
},
icon: AnimatedBuilder(
animation: _pulseController,
builder: (context, child) {
return Transform.rotate(
angle: _pulseController.value * 6.2832,
child: Icon(
Icons.refresh_rounded,
size: 16,
color: _isLoadingUsd || _isLoadingZwG
? (isDark ? Colors.white24 : Colors.grey.shade400)
: theme.colorScheme.primary,
),
);
},
),
label: Text(
_isLoadingUsd || _isLoadingZwG
? 'Refreshing...'
: 'Refresh Balances',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: _isLoadingUsd || _isLoadingZwG
? (isDark ? Colors.white24 : Colors.grey.shade400)
: theme.colorScheme.primary,
),
),
),
),
],
),
);
}
Widget _buildBalanceCard({
required ThemeData theme,
required bool isDark,
required String currencyCode,
required String currencyName,
required String flagAsset,
double? balance,
required bool isLoading,
String? error,
required List<Color> gradientColors,
}) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: LinearGradient(
colors: gradientColors,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: gradientColors[0].withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Top row: flag + currency code
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 24,
height: 24,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Colors.white.withValues(alpha: 0.3),
width: 1.5,
),
),
child: ClipOval(
child: Image.asset(
flagAsset,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Icon(
Icons.monetization_on_outlined,
size: 14,
color: Colors.white.withValues(alpha: 0.8),
),
),
),
),
const SizedBox(width: 8),
Text(
currencyCode,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: Colors.white,
letterSpacing: 0.5,
),
),
],
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
currencyName,
style: TextStyle(
fontSize: 8,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: 0.9),
),
),
),
],
),
const SizedBox(height: 16),
// Balance amount
if (isLoading)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 80,
height: 14,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(4),
),
),
const SizedBox(height: 4),
Container(
width: 50,
height: 10,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
),
],
)
else if (error != null)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.error_outline_rounded,
color: Colors.white.withValues(alpha: 0.7),
size: 20,
),
const SizedBox(height: 4),
Text(
'Unavailable',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: 0.7),
),
),
],
)
else ...[
Text(
_formatBalance(balance ?? 0.0),
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: 0.5,
height: 1.1,
),
),
const SizedBox(height: 4),
Text(
currencyCode == 'USD' ? 'Available Balance' : 'Available Balance',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w400,
color: Colors.white.withValues(alpha: 0.7),
),
),
],
],
),
);
}
Widget _buildSectionLabel(
ThemeData theme,
bool isDark,
IconData icon,
String label,
) {
return Row(
children: [
Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 16, color: theme.colorScheme.primary),
),
const SizedBox(width: 10),
Text(
label,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
letterSpacing: 0.3,
),
),
],
);
}
String _formatBalance(double balance) {
final formatter = NumberFormat('#,##0.00', 'en_US');
return '\$${formatter.format(balance)}';
}
}

View File

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

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/models/responsive_policy.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:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
class ConfirmScreen extends StatefulWidget { class ConfirmScreen extends StatefulWidget {
@@ -65,17 +66,11 @@ class _ConfirmScreenState extends State<ConfirmScreen>
final response = await controller.doTransaction(); final response = await controller.doTransaction();
if (!response.isSuccess) { if (!response.isSuccess) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( AppSnackBar.showError(
SnackBar( context,
content: Text( controller.model.errorMessage ??
controller.model.errorMessage ?? response.error ??
response.error ?? "Transaction failed",
"Transaction failed",
),
behavior: SnackBarBehavior.floating,
width: 600,
backgroundColor: Colors.deepOrange,
),
); );
} }
return; return;
@@ -83,6 +78,41 @@ class _ConfirmScreenState extends State<ConfirmScreen>
if (!mounted) return; if (!mounted) return;
// Check if the selected payment processor is a wallet (e.g. Velocity)
if (controller
.transactionController
.model
.selectedPaymentProcessor
.label ==
'WALLET') {
final otp = await _showOtpBottomSheet();
if (otp == null) return; // User cancelled or dismissed the bottom sheet
if (!mounted) return;
final transactionId =
controller.transactionController.model.confirmationData['id'];
final otpResponse = await controller.updateVelocityTransactionOtp(
transactionId,
{'verificationCode': otp},
);
if (!otpResponse.isSuccess) {
if (context.mounted) {
AppSnackBar.showError(
context,
controller.model.errorMessage ??
otpResponse.error ??
"OTP verification failed",
);
}
return;
}
if (!mounted) return;
}
// Proceed with normal flow after OTP step (or skip if not WALLET)
if (controller if (controller
.transactionController .transactionController
.model .model
@@ -104,6 +134,174 @@ class _ConfirmScreenState extends State<ConfirmScreen>
} }
} }
Future<String?> _showOtpBottomSheet() async {
final theme = Theme.of(context);
final isDark = theme.brightness == Brightness.dark;
final otpController = TextEditingController();
final formKey = GlobalKey<FormState>();
return showModalBottomSheet<String>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (sheetContext) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(sheetContext).viewInsets.bottom,
),
child: Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(24),
),
),
padding: const EdgeInsets.fromLTRB(20, 12, 20, 24),
child: Form(
key: formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
),
Center(
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: theme.colorScheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: Icon(
Icons.verified_user_rounded,
size: 28,
color: theme.colorScheme.primary,
),
),
),
const SizedBox(height: 16),
Text(
'OTP Verification',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w700,
color: isDark ? Colors.white : Colors.black87,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'An OTP has been sent to your registered mobile number/email. Please enter it below to proceed.',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.grey.shade500,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 20),
TextFormField(
controller: otpController,
keyboardType: TextInputType.number,
textAlign: TextAlign.center,
maxLength: 6,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: 8,
color: isDark ? Colors.white : Colors.black87,
),
decoration: InputDecoration(
counterText: '',
hintText: '------',
hintStyle: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
letterSpacing: 8,
color: Colors.grey.shade400,
),
filled: true,
fillColor: isDark
? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(
color: theme.colorScheme.primary,
width: 2,
),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: BorderSide(color: Colors.red.shade400),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter the OTP';
}
if (value.trim().length < 4) {
return 'OTP must be at least 4 digits';
}
return null;
},
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
if (formKey.currentState?.validate() ?? false) {
Navigator.of(
sheetContext,
).pop(otpController.text.trim());
}
},
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
elevation: 0,
),
child: const Text(
'Submit',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
),
),
),
),
],
),
),
),
);
},
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(

View File

@@ -3,6 +3,7 @@ import 'dart:async';
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/screens/transaction_controller.dart'; import 'package:qpay/screens/transaction_controller.dart';
import 'package:qpay/widgets/app_snack_bar.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class GatewayModel { class GatewayModel {
@@ -30,13 +31,7 @@ class GatewayController extends ChangeNotifier {
void _showErrorSnackBar(String? message) { void _showErrorSnackBar(String? message) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar( AppSnackBar.showError(context, message.toString());
SnackBar(
content: Text(message.toString()),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.deepOrange,
),
);
}); });
} }
@@ -54,8 +49,9 @@ class GatewayController extends ChangeNotifier {
Future<ApiResponse<Map<String, dynamic>>> poll(String uid) async { Future<ApiResponse<Map<String, dynamic>>> poll(String uid) async {
startLoading(); startLoading();
try { try {
dynamic workflowResponse = dynamic workflowResponse = await http.get(
await http.get('/public/transaction/poll/$uid'); '/public/transaction/poll/$uid',
);
logger.i(workflowResponse.toString()); logger.i(workflowResponse.toString());
@@ -67,6 +63,10 @@ class GatewayController extends ChangeNotifier {
finishLoading(); finishLoading();
if (model.status == 'SUCCESS') { if (model.status == 'SUCCESS') {
transactionController.updateReceiptData(response); transactionController.updateReceiptData(response);
} else {
model.status = 'FAILED';
model.errorMessage = response['errorMessage'];
return ApiResponse.failure(response['errorMessage']);
} }
notifyListeners(); notifyListeners();
return ApiResponse.success(Map<String, dynamic>.from(response)); return ApiResponse.success(Map<String, dynamic>.from(response));
@@ -84,7 +84,7 @@ 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 30 times (~5 minutes at 10-second intervals)
int maxAttempts = 30; int maxAttempts = 15;
int attempt = 0; int attempt = 0;
while (!model.isCancelled && attempt < maxAttempts) { while (!model.isCancelled && attempt < maxAttempts) {
@@ -108,6 +108,9 @@ class GatewayController extends ChangeNotifier {
// Otherwise (e.g. PENDING), continue polling // Otherwise (e.g. PENDING), continue polling
} else { } else {
// Network error — show failure UI and stop // Network error — show failure UI and stop
model.isCancelled = true;
model.status = 'FAILED';
model.errorMessage = result.error;
return result; return result;
} }
} }
@@ -123,7 +126,8 @@ class GatewayController extends ChangeNotifier {
// Alternative method using Timer instead of while loop // Alternative method using Timer instead of while loop
Future<ApiResponse<Map<String, dynamic>>> pollTransactionWithTimer( Future<ApiResponse<Map<String, dynamic>>> pollTransactionWithTimer(
String uid) async { String uid,
) async {
_pollTimer = Timer.periodic(const Duration(seconds: 5), (timer) async { _pollTimer = Timer.periodic(const Duration(seconds: 5), (timer) async {
if (model.isCancelled) { if (model.isCancelled) {
timer.cancel(); timer.cancel();
@@ -150,7 +154,8 @@ class GatewayController extends ChangeNotifier {
// This returns immediately; the caller should use the timer callback approach. // This returns immediately; the caller should use the timer callback approach.
// Consider refactoring this to use poll(String uid) directly for a consistent API. // Consider refactoring this to use poll(String uid) directly for a consistent API.
return ApiResponse.failure( return ApiResponse.failure(
"Polling via timer started; use a different flow for the result"); "Polling via timer started; use a different flow for the result",
);
} }
@override @override
@@ -159,4 +164,4 @@ class GatewayController extends ChangeNotifier {
_pollTimer?.cancel(); // Cancel timer if it exists _pollTimer?.cancel(); // Cancel timer if it exists
super.dispose(); super.dispose();
} }
} }

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.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/screens/gateway/gateway_controller.dart'; import 'package:qpay/screens/gateway/gateway_controller.dart';
import 'package:qpay/widgets/app_snack_bar.dart';
import 'package:webview_flutter/webview_flutter.dart'; import 'package:webview_flutter/webview_flutter.dart';
class GatewayScreen extends StatefulWidget { class GatewayScreen extends StatefulWidget {
@@ -69,15 +70,11 @@ class _GatewayScreenState extends State<GatewayScreen> {
return; return;
} }
ScaffoldMessenger.of(context).showSnackBar( AppSnackBar.show(
SnackBar( context,
content: Text( 'Payment was successful, please wait while we process your order.',
"Payment was successful, please wait while we process your order.", backgroundColor: Colors.black87,
), duration: const Duration(seconds: 10),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.black87,
duration: const Duration(seconds: 10),
),
); );
setState(() { setState(() {

View File

@@ -0,0 +1,439 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:qpay/screens/groups/models/group_batch_model.dart';
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
import 'package:qpay/screens/home/home_controller.dart';
import 'package:qpay/screens/pay/pay_controller.dart';
import 'package:qpay/widgets/app_snack_bar.dart';
import 'package:shared_preferences/shared_preferences.dart';
class BatchCreateScreen extends StatefulWidget {
final RecipientGroup group;
const BatchCreateScreen({super.key, required this.group});
@override
State<BatchCreateScreen> createState() => _BatchCreateScreenState();
}
class _BatchCreateScreenState extends State<BatchCreateScreen> {
late BatchController controller;
final _formKey = GlobalKey<FormState>();
final _descriptionController = TextEditingController();
final _amountController = TextEditingController();
final _debitPhoneController = TextEditingController();
String _currency = 'USD';
bool _loadingDropdowns = true;
@override
void initState() {
super.initState();
controller = BatchController(context);
_init();
}
Future<void> _init() async {
final prefs = await SharedPreferences.getInstance();
if (prefs.containsKey('phone')) {
_debitPhoneController.text = prefs.getString('phone')!;
}
await Future.wait([controller.loadProviders()]);
if (mounted) setState(() => _loadingDropdowns = false);
}
@override
void dispose() {
controller.dispose();
_descriptionController.dispose();
_amountController.dispose();
_debitPhoneController.dispose();
super.dispose();
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
final selectedProvider = controller.model.selectedProvider;
if (selectedProvider == null) {
AppSnackBar.show(context, 'Please select a bill provider.');
return;
}
final amount = double.tryParse(_amountController.text.trim());
if (amount == null || amount <= 0) {
AppSnackBar.show(context, 'Please enter a valid amount.');
return;
}
final prefs = await SharedPreferences.getInstance();
final userId = prefs.getString('userId') ?? '';
final transactionTemplate = <String, dynamic>{
'type': 'CONFIRM',
'billClientId': selectedProvider.clientId,
'debitPhone': _debitPhoneController.text.trim(),
'debitRef': 'Velocity',
'debitCurrency': _currency,
'billName': selectedProvider.name,
'providerImage': selectedProvider.image,
'providerLabel': selectedProvider.label,
'creditName': '',
'creditEmail': '',
'productUid': '',
'billProductName': '',
'region': 'ZW',
};
final req = CreateBatchRequest(
recipientGroupId: widget.group.id ?? '',
userId: userId,
currency: _currency,
description: _descriptionController.text.trim(),
amount: amount,
transactionTemplate: transactionTemplate,
);
final result = await controller.createBatch(req);
if (!mounted) return;
if (result.isSuccess) {
final batch = result.data!;
context.pushReplacement('/groups/${widget.group.id}/batches/${batch.id}');
} else {
AppSnackBar.showError(context, result.error ?? 'Failed to create batch.');
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('New Batch'), centerTitle: true),
body: ListenableBuilder(
listenable: controller,
builder: (context, _) {
if (_loadingDropdowns) {
return const Center(child: CircularProgressIndicator());
}
return Center(
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth > ResponsivePolicy.md
? ResponsivePolicy.md.toDouble()
: constraints.maxWidth;
return SizedBox(
width: width,
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Form(
key: _formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildSectionLabel('Bill Provider'),
const SizedBox(height: 8),
_buildProviderGrid(constraints),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
textInputAction: TextInputAction.next,
decoration: InputDecoration(
labelText: 'Description (optional)',
hintText: 'e.g. Monthly salaries for January',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
flex: 2,
child: TextFormField(
controller: _amountController,
keyboardType:
const TextInputType.numberWithOptions(
decimal: true,
),
textInputAction: TextInputAction.next,
decoration: InputDecoration(
labelText:
'Amount each recipient will receive',
hintText: 'Amount',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (v) =>
(v == null || v.trim().isEmpty)
? 'Required'
: null,
),
),
const SizedBox(width: 12),
Expanded(
child: DropdownButtonFormField<String>(
value: _currency,
decoration: InputDecoration(
labelText: 'Currency',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
items: const [
DropdownMenuItem(
value: 'USD',
child: Text('USD'),
),
DropdownMenuItem(
value: 'ZWG',
child: Text('ZWG'),
),
],
onChanged: (v) {
if (v != null)
setState(() => _currency = v);
},
),
),
],
),
const SizedBox(height: 16),
TextFormField(
controller: _debitPhoneController,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: 'Your Phone Number',
hintText: '263773000000',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
validator: (v) => (v == null || v.trim().isEmpty)
? 'Phone number is required'
: null,
),
const SizedBox(height: 32),
ElevatedButton(
onPressed: controller.model.isActing
? null
: _submit,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: controller.model.isActing
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text(
'Create Batch',
style: TextStyle(fontSize: 16),
),
),
],
),
),
),
);
},
),
);
},
),
);
}
Widget _buildSectionLabel(String text) {
return Text(
text,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Colors.grey.shade700,
),
);
}
static const Map<String, Color> _categoryColorMap = {
'ECONET': Color(0xFF1A73E8),
'NETONE': Color.fromARGB(255, 230, 127, 0),
'TELECEL': Color(0xFF7B1FA2),
'ZESA': Color.fromARGB(255, 230, 58, 0),
};
Color _resolveProviderColor(BillProvider provider) {
final exact = _categoryColorMap[provider.category.toUpperCase()];
if (exact != null) return exact;
final labelMatch = _categoryColorMap[provider.label.toUpperCase()];
if (labelMatch != null) return labelMatch;
final name = provider.name.toUpperCase();
for (final entry in _categoryColorMap.entries) {
if (name.contains(entry.key)) return entry.value;
}
final hash = provider.clientId.hashCode;
final hue = (hash % 360).toDouble();
return HSVColor.fromAHSV(1.0, hue, 0.75, 0.85).toColor();
}
Widget _buildProviderGrid(BoxConstraints constraints) {
if (controller.model.providers.isEmpty) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
border: Border.all(color: Colors.black26),
borderRadius: BorderRadius.circular(12),
),
child: Text(
'No providers available',
style: TextStyle(color: Colors.grey.shade600),
),
);
}
final isDark = Theme.of(context).brightness == Brightness.dark;
return LayoutBuilder(
builder: (context, gridConstraints) {
// gridConstraints.maxWidth reflects the actual rendered width of this widget
// 3-column grid: subtract 2 gaps (10*2 = 20), then divide by 3
final cardWidth = (gridConstraints.maxWidth - 20) / 3;
return Wrap(
spacing: 10,
runSpacing: 10,
children: controller.model.providers
.map(
(provider) => _buildProviderCard(provider, isDark, cardWidth),
)
.toList(),
);
},
);
}
Widget _buildProviderCard(BillProvider provider, bool isDark, double width) {
final isSelected =
controller.model.selectedProvider?.clientId == provider.clientId;
final accent = _resolveProviderColor(provider);
return SizedBox(
width: width,
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => controller.selectProvider(provider),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E1E1E) : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSelected
? accent
: isDark
? Colors.white.withValues(alpha: 0.1)
: Colors.black.withValues(alpha: 0.06),
width: isSelected ? 1.5 : 1,
),
),
child: Stack(
children: [
Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 28,
height: 28,
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Image(
image: AssetImage("assets/${provider.image}"),
errorBuilder: (_, __, ___) => Icon(
Icons.payment_rounded,
size: 14,
color: accent,
),
),
),
],
),
const SizedBox(height: 8),
Text(
provider.name,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : Colors.black87,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
provider.description,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w400,
color: isDark ? Colors.white54 : Colors.grey.shade500,
height: 1.3,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
if (isSelected)
Positioned(
top: -2,
right: -2,
child: Container(
width: 18,
height: 18,
decoration: BoxDecoration(
color: accent,
shape: BoxShape.circle,
border: Border.all(
color: isDark
? const Color(0xFF1E1E1E)
: Colors.white,
width: 1.5,
),
),
child: const Icon(
Icons.check_rounded,
size: 11,
color: Colors.white,
),
),
),
],
),
),
),
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,172 @@
import 'package:flutter/material.dart';
import 'package:qpay/screens/groups/models/group_batch_model.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/widgets/status_chip_widget.dart';
class BatchItemScreen extends StatelessWidget {
final GroupBatchItem item;
const BatchItemScreen({super.key, required this.item});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(item.recipientName ?? 'Batch Item'),
centerTitle: true,
),
body: 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: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildHeaderCard(context),
const SizedBox(height: 16),
_buildDetailsCard(),
],
),
),
);
},
),
),
);
}
Widget _buildHeaderCard(BuildContext context) {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.black12.withAlpha(30)),
),
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
CircleAvatar(
radius: 32,
backgroundColor: Theme.of(
context,
).colorScheme.primary.withAlpha(30),
child: Text(
item.recipientName?.isNotEmpty == true
? item.recipientName![0].toUpperCase()
: '?',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
),
const SizedBox(height: 12),
Text(
item.recipientName ?? '',
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
item.recipientPhone ?? '',
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
),
const SizedBox(height: 12),
StatusChip(status: item.status ?? 'PENDING'),
],
),
),
);
}
Widget _buildDetailsCard() {
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.black12.withAlpha(30)),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Details',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
_detailRow('Amount', _formatAmount()),
_detailRow('Status', item.status ?? ''),
if (item.transactionId != null)
_detailRow('Transaction ID', item.transactionId!),
if (item.groupBatchId != null)
_detailRow('Batch ID', item.groupBatchId!),
if (item.createdAt != null) _detailRow('Created', item.createdAt!),
if (item.errorMessage != null && item.errorMessage!.isNotEmpty) ...[
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.withAlpha(20),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.withAlpha(60)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Error',
style: TextStyle(
fontWeight: FontWeight.w600,
color: Colors.red,
fontSize: 13,
),
),
const SizedBox(height: 4),
Text(
item.errorMessage!,
style: const TextStyle(fontSize: 13, color: Colors.red),
),
],
),
),
],
],
),
),
);
}
String _formatAmount() {
if (item.amount == null) return '';
return item.amount!.toStringAsFixed(2);
}
Widget _detailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 120,
child: Text(
label,
style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
),
),
Expanded(child: Text(value, style: const TextStyle(fontSize: 13))),
],
),
);
}
}

View File

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

View File

@@ -0,0 +1,255 @@
import 'package:flutter/material.dart';
import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart';
import 'package:qpay/models/pageable_model.dart';
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
class GroupScreenModel {
List<RecipientGroup> groups = [];
List<RecipientGroupMember> members = [];
bool isLoading = false;
bool isLoadingMore = false;
String? errorMessage;
int currentPage = 0;
int totalPages = 0;
int totalElements = 0;
String searchQuery = '';
bool selectMode = false;
Set<String> selectedGroupIds = {};
}
class GroupController extends ChangeNotifier {
final GroupScreenModel model = GroupScreenModel();
final Http http = Http();
BuildContext context;
bool _disposed = false;
GroupController(this.context);
@override
void dispose() {
_disposed = true;
super.dispose();
}
dynamic _unwrap(dynamic response) {
if (response is Map && response.containsKey('body')) {
return response['body'];
}
return response;
}
String buildQueryParameters(Map<String, String> params) {
if (params.isEmpty) {
return '';
}
String query = '';
params.forEach((key, value) {
query += '$key=$value&';
});
return query.substring(0, query.length - 1);
}
Future<ApiResponse<PageableModel>> listGroups({
required String userId,
int page = 0,
int size = 20,
String? name,
}) async {
if (page == 0) {
model.isLoading = true;
model.groups = [];
model.currentPage = 0;
} else {
model.isLoadingMore = true;
}
if (!_disposed) notifyListeners();
try {
final params = <String, String>{
'userId': userId,
'page': page.toString(),
'size': size.toString(),
};
if (name != null && name.isNotEmpty) {
params['name'] = name;
}
final raw = await http.get(
'/public/recipient-groups?${buildQueryParameters(params)}',
);
final response = _unwrap(raw);
final pageableModel =
PageableModel.fromJson(response as Map<String, dynamic>);
final newGroups = pageableModel.content
.map((e) => RecipientGroup.fromJson(e as Map<String, dynamic>))
.toList();
if (page == 0) {
model.groups = newGroups;
} else {
model.groups.addAll(newGroups);
}
model.currentPage = pageableModel.number;
model.totalPages = pageableModel.totalPages;
model.totalElements = pageableModel.totalElements;
model.isLoading = false;
model.isLoadingMore = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(pageableModel);
} catch (e) {
logger.e(e);
model.errorMessage = e.toString();
if (page == 0) {
model.groups = [];
}
model.isLoading = false;
model.isLoadingMore = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to load groups');
}
}
Future<void> searchGroups({
required String userId,
String? name,
}) async {
model.searchQuery = name ?? '';
await listGroups(userId: userId, page: 0, name: name);
}
Future<void> loadNextPage({required String userId}) async {
if (model.isLoadingMore || model.currentPage + 1 >= model.totalPages) {
return;
}
await listGroups(
userId: userId,
page: model.currentPage + 1,
name: model.searchQuery.isNotEmpty ? model.searchQuery : null,
);
}
Future<ApiResponse<List<RecipientGroupMember>>> listMembers(
String groupId,
) async {
model.isLoading = true;
if (!_disposed) notifyListeners();
try {
final raw = await http.get(
'/public/recipient-groups/members?recipientGroupId=$groupId',
);
final response = _unwrap(raw);
final List<dynamic> content;
if (response is List) {
content = response;
} else if (response is Map && response.containsKey('content')) {
content = response['content'] as List<dynamic>;
} else {
content = [];
}
model.members = content
.map((e) => RecipientGroupMember.fromJson(e as Map<String, dynamic>))
.toList();
model.isLoading = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(model.members);
} catch (e, s) {
logger.e(e);
logger.e(s);
model.errorMessage = e.toString();
model.members = [];
model.isLoading = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to load members');
}
}
Future<ApiResponse<RecipientGroup>> createGroup(
CreateGroupRequest req,
) async {
model.isLoading = true;
if (!_disposed) notifyListeners();
try {
final raw = await http.post('/public/recipient-groups', req.toJson());
final response = _unwrap(raw);
final group = RecipientGroup.fromJson(response as Map<String, dynamic>);
model.groups = [group, ...model.groups];
model.isLoading = false;
if (!_disposed) notifyListeners();
return ApiResponse.success(group);
} catch (e) {
logger.e(e);
model.errorMessage = e.toString();
model.isLoading = false;
if (!_disposed) notifyListeners();
return ApiResponse.failure('Failed to create group');
}
}
Future<void> deleteGroup({
required String groupId,
required String userId,
}) async {
try {
await http.delete(
'/public/recipient-groups/delete/$groupId?userId=$userId',
);
model.groups.removeWhere((g) => g.id == groupId);
model.selectedGroupIds.remove(groupId);
if (!_disposed) notifyListeners();
} catch (e) {
logger.e(e);
model.errorMessage = e.toString();
if (!_disposed) notifyListeners();
}
}
void toggleSelectMode() {
model.selectMode = !model.selectMode;
if (!model.selectMode) {
model.selectedGroupIds.clear();
}
if (!_disposed) notifyListeners();
}
void toggleGroupSelection(String groupId) {
if (model.selectedGroupIds.contains(groupId)) {
model.selectedGroupIds.remove(groupId);
} else {
model.selectedGroupIds.add(groupId);
}
if (!_disposed) notifyListeners();
}
Future<void> removeMember({
required String groupId,
required String recipientId,
required String userId,
}) async {
try {
await http.delete(
'/public/recipient-groups/$groupId/members/$recipientId?userId=$userId',
);
model.members.removeWhere((m) => m.id == recipientId);
if (!_disposed) notifyListeners();
} catch (e) {
logger.e(e);
model.errorMessage = e.toString();
if (!_disposed) notifyListeners();
}
}
Future<void> deleteSelectedGroups({required String userId}) async {
final idsToDelete = List<String>.from(model.selectedGroupIds);
model.selectMode = false;
model.selectedGroupIds.clear();
if (!_disposed) notifyListeners();
for (final id in idsToDelete) {
await deleteGroup(groupId: id, userId: userId);
}
}
}

View File

@@ -0,0 +1,220 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/groups/controllers/group_controller.dart';
import 'package:qpay/widgets/app_snack_bar.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../recipient/recipients_controller.dart';
class GroupCreateScreen extends StatefulWidget {
const GroupCreateScreen({super.key});
@override
State<GroupCreateScreen> createState() => _GroupCreateScreenState();
}
class _GroupCreateScreenState extends State<GroupCreateScreen> {
late GroupController controller;
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
final _descriptionController = TextEditingController();
final _recipientsController = TextEditingController();
@override
void initState() {
super.initState();
controller = GroupController(context);
}
@override
void dispose() {
controller.dispose();
_nameController.dispose();
_descriptionController.dispose();
_recipientsController.dispose();
super.dispose();
}
List<Recipient> _parseRecipients(String raw) {
final lines = raw
.split('\n')
.map((l) => l.trim())
.where((l) => l.isNotEmpty)
.toList();
final List<Recipient> members = [];
for (final line in lines) {
final parts = line.split(',');
if (parts.length >= 2) {
final name = parts[0].trim();
final account = parts[1].trim();
if (name.isNotEmpty && account.isNotEmpty) {
members.add(
Recipient(
name: name,
account: account,
phoneNumber: account,
latestProviderLabel: '',
),
);
}
}
}
return members;
}
Future<void> _submit() async {
if (!_formKey.currentState!.validate()) return;
final recipients = _parseRecipients(_recipientsController.text);
if (recipients.isEmpty) {
AppSnackBar.show(
context,
'Please add at least one recipient in name,account format.',
);
return;
}
final prefs = await SharedPreferences.getInstance();
final userId = prefs.getString('userId') ?? '';
final req = CreateGroupRequest(
name: _nameController.text.trim(),
description: _descriptionController.text.trim().isEmpty
? null
: _descriptionController.text.trim(),
userId: userId,
recipients: recipients,
);
final result = await controller.createGroup(req);
if (!mounted) return;
if (result.isSuccess) {
AppSnackBar.showSuccess(context, 'Group created successfully.');
context.pop(true); // Return true to indicate a new group was created
} else {
AppSnackBar.showError(
context,
result.error ?? 'Failed to create group.',
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('New Group'), 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: _nameController,
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: 24),
Text(
'Recipients',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey.shade800,
),
),
const SizedBox(height: 4),
Text(
'Enter one recipient per line as: name,account',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 8),
TextFormField(
controller: _recipientsController,
maxLines: 8,
keyboardType: TextInputType.multiline,
textInputAction: TextInputAction.newline,
decoration: InputDecoration(
hintText:
'Alice,263773000001\nBob,263773000002\nCarol,263773000003',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
alignLabelWithHint: true,
),
),
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(
'Create Group',
style: TextStyle(fontSize: 16),
),
),
],
),
),
),
);
},
),
);
},
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,433 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/groups/controllers/group_controller.dart';
import 'package:shared_preferences/shared_preferences.dart';
class GroupsScreen extends StatefulWidget {
const GroupsScreen({super.key});
@override
State<GroupsScreen> createState() => _GroupsScreenState();
}
class _GroupsScreenState extends State<GroupsScreen> {
late GroupController controller;
late SharedPreferences prefs;
final _searchController = TextEditingController();
final _scrollController = ScrollController();
@override
void initState() {
super.initState();
controller = GroupController(context);
_loadData();
_scrollController.addListener(_onScroll);
}
void _onScroll() {
if (_scrollController.position.pixels >=
_scrollController.position.maxScrollExtent - 200) {
controller.loadNextPage(userId: prefs.getString('userId') ?? '');
}
}
Future<void> _loadData() async {
prefs = await SharedPreferences.getInstance();
final userId = prefs.getString('userId') ?? '';
await controller.listGroups(userId: userId);
}
void _onSearchChanged(String value) {
final userId = prefs.getString('userId') ?? '';
controller.searchGroups(
userId: userId,
name: value.isNotEmpty ? value : null,
);
}
void _onDeleteSelected() async {
final userId = prefs.getString('userId') ?? '';
final count = controller.model.selectedGroupIds.length;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete groups'),
content: Text('Are you sure you want to delete $count group(s)?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Delete'),
),
],
),
);
if (confirmed == true) {
await controller.deleteSelectedGroups(userId: userId);
}
}
@override
void dispose() {
_searchController.dispose();
_scrollController.dispose();
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text(
'Groups',
style: TextStyle(fontSize: 20, color: Colors.black),
),
centerTitle: true,
),
body: ListenableBuilder(
listenable: controller,
builder: (context, _) {
return Column(
children: [
Expanded(
child: SingleChildScrollView(
controller: _scrollController,
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth > ResponsivePolicy.md
? ResponsivePolicy.md.toDouble()
: constraints.maxWidth;
return SizedBox(
width: width,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSearchRow(),
const SizedBox(height: 16),
_buildContent(),
],
),
),
);
},
),
),
),
),
if (controller.model.selectMode) _buildSelectionBottomBar(),
],
);
},
),
);
}
Widget _buildSearchRow() {
return Row(
children: [
Expanded(
child: TextFormField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search groups by name...',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
_onSearchChanged('');
},
)
: null,
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
),
),
),
onChanged: _onSearchChanged,
),
),
const SizedBox(width: 8),
_buildIconButton(
icon: controller.model.selectMode
? Icons.checklist
: Icons.checklist_outlined,
tooltip: 'Select groups',
isActive: controller.model.selectMode,
onPressed: () => controller.toggleSelectMode(),
),
const SizedBox(width: 8),
_buildIconButton(
icon: Icons.refresh,
tooltip: 'Refresh groups',
isActive: false,
onPressed: _loadData,
),
const SizedBox(width: 8),
_buildIconButton(
icon: Icons.add,
tooltip: 'Create new group',
isActive: false,
onPressed: _createGroup,
),
],
);
}
_createGroup() async {
final response = await context.push('/groups/create');
if (response == true) {
_loadData();
}
}
Widget _buildIconButton({
required IconData icon,
required String tooltip,
required bool isActive,
required VoidCallback onPressed,
}) {
return Container(
decoration: BoxDecoration(
color: isActive
? Theme.of(context).colorScheme.primary
: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
icon: Icon(icon, color: isActive ? Colors.white : Colors.grey.shade700),
tooltip: tooltip,
onPressed: onPressed,
),
);
}
Widget _buildContent() {
if (controller.model.isLoading) {
return const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 60),
child: CircularProgressIndicator(),
),
);
}
if (controller.model.groups.isEmpty) {
return Center(child: _buildEmptyState());
}
return Column(
children: [
if (controller.model.selectMode)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Text(
'${controller.model.selectedGroupIds.length} selected',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.grey.shade600,
),
),
],
),
),
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: controller.model.groups.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (context, index) {
return _buildGroupCard(controller.model.groups[index]);
},
),
if (controller.model.isLoadingMore)
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
],
);
}
Widget _buildEmptyState() {
return Column(
children: [
const SizedBox(height: 40),
Icon(Icons.group_outlined, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
const Text(
'No groups yet.',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Text(
'Create a group to start sending batch payments.',
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
textAlign: TextAlign.center,
),
],
);
}
Widget _buildGroupCard(RecipientGroup group) {
final isSelected = controller.model.selectedGroupIds.contains(group.id);
final inSelectMode = controller.model.selectMode;
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: inSelectMode && isSelected
? Theme.of(context).colorScheme.primary
: Colors.black12.withAlpha(30),
width: inSelectMode && isSelected ? 2 : 1,
),
),
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: inSelectMode
? () => controller.toggleGroupSelection(group.id!)
: () => context.push('/groups/${group.id}', extra: group),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
if (inSelectMode)
Padding(
padding: const EdgeInsets.only(right: 12),
child: Icon(
isSelected
? Icons.check_box
: Icons.check_box_outline_blank,
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.grey.shade400,
),
),
CircleAvatar(
backgroundColor: Theme.of(
context,
).colorScheme.primary.withAlpha(30),
child: Text(
_getGroupAbbreviation(group.name),
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
fontSize: 16,
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
group.name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
if (group.description != null &&
group.description!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
group.description!,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
if (!inSelectMode)
Icon(Icons.chevron_right, color: Colors.grey.shade400),
],
),
),
),
);
}
String _getGroupAbbreviation(String name) {
if (name.isEmpty) return '?';
final words = name.trim().split(RegExp(r'\s+'));
if (words.length >= 2) {
return '${words.first[0]}${words.last[0]}'.toUpperCase();
}
return name.length >= 2
? name.substring(0, 2).toUpperCase()
: name[0].toUpperCase();
}
Widget _buildSelectionBottomBar() {
final selectedCount = controller.model.selectedGroupIds.length;
return Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Text(
'$selectedCount selected',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
const Spacer(),
TextButton.icon(
onPressed: selectedCount > 0 ? _onDeleteSelected : null,
icon: const Icon(Icons.delete_outline, size: 20),
label: const Text('Delete'),
style: TextButton.styleFrom(foregroundColor: Colors.red),
),
],
),
),
),
);
}
}

View File

@@ -0,0 +1,143 @@
import 'package:json_annotation/json_annotation.dart';
part 'group_batch_model.g.dart';
@JsonSerializable(explicitToJson: true)
class GroupBatch {
final String? id;
final String? recipientGroupId;
final String? userId;
final String? currency;
final String? description;
final double? value;
final String? status;
final String? paymentProcessorLabel;
final String? paymentProcessorName;
final String? paymentProcessorImage;
final String? transactionTemplate;
final String? createdAt;
final int? count;
final int? successfulCount;
final int? failedCount;
const GroupBatch({
this.id,
this.recipientGroupId,
this.userId,
this.currency,
this.description,
this.value,
this.status,
this.paymentProcessorLabel,
this.paymentProcessorName,
this.paymentProcessorImage,
this.transactionTemplate,
this.createdAt,
this.count,
this.successfulCount,
this.failedCount,
});
factory GroupBatch.fromJson(Map<String, dynamic> json) =>
_$GroupBatchFromJson(json);
Map<String, dynamic> toJson() => _$GroupBatchToJson(this);
}
@JsonSerializable(explicitToJson: true)
class GroupBatchItem {
final String? id;
final String? groupBatchId;
final String? recipientName;
final String? recipientPhone;
final double? amount;
final String? status;
final String? transactionId;
final String? errorMessage;
final String? createdAt;
const GroupBatchItem({
this.id,
this.groupBatchId,
this.recipientName,
this.recipientPhone,
this.amount,
this.status,
this.transactionId,
this.errorMessage,
this.createdAt,
});
factory GroupBatchItem.fromJson(Map<String, dynamic> json) =>
_$GroupBatchItemFromJson(json);
Map<String, dynamic> toJson() => _$GroupBatchItemToJson(this);
}
@JsonSerializable(explicitToJson: true)
class CreateBatchRequest {
final String recipientGroupId;
final String userId;
final String currency;
final String description;
final double amount;
final String? paymentProcessorLabel;
final String? paymentProcessorName;
final String? paymentProcessorImage;
final Map<String, dynamic> transactionTemplate;
const CreateBatchRequest({
required this.recipientGroupId,
required this.userId,
required this.currency,
required this.description,
required this.amount,
this.paymentProcessorLabel,
this.paymentProcessorName,
this.paymentProcessorImage,
required this.transactionTemplate,
});
factory CreateBatchRequest.fromJson(Map<String, dynamic> json) =>
_$CreateBatchRequestFromJson(json);
Map<String, dynamic> toJson() => _$CreateBatchRequestToJson(this);
}
@JsonSerializable()
class BatchActionRequest {
final String userId;
final String idempotencyKey;
const BatchActionRequest({
required this.userId,
required this.idempotencyKey,
});
factory BatchActionRequest.fromJson(Map<String, dynamic> json) =>
_$BatchActionRequestFromJson(json);
Map<String, dynamic> toJson() => _$BatchActionRequestToJson(this);
}
@JsonSerializable(explicitToJson: true)
class GroupBatchUpdateRequest {
String? id;
String? userId;
String? paymentProcessorLabel;
String? paymentProcessorName;
String? paymentProcessorImage;
GroupBatchUpdateRequest({
this.id,
this.userId,
this.paymentProcessorLabel,
this.paymentProcessorName,
this.paymentProcessorImage,
});
factory GroupBatchUpdateRequest.fromJson(Map<String, dynamic> json) =>
_$GroupBatchUpdateRequestFromJson(json);
Map<String, dynamic> toJson() => _$GroupBatchUpdateRequestToJson(this);
}

View File

@@ -0,0 +1,128 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'group_batch_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
GroupBatch _$GroupBatchFromJson(Map<String, dynamic> json) => GroupBatch(
id: json['id'] as String?,
recipientGroupId: json['recipientGroupId'] as String?,
userId: json['userId'] as String?,
currency: json['currency'] as String?,
description: json['description'] as String?,
value: (json['value'] as num?)?.toDouble(),
status: json['status'] as String?,
paymentProcessorLabel: json['paymentProcessorLabel'] as String?,
paymentProcessorName: json['paymentProcessorName'] as String?,
paymentProcessorImage: json['paymentProcessorImage'] as String?,
transactionTemplate: json['transactionTemplate'] as String?,
createdAt: json['createdAt'] as String?,
count: (json['count'] as num?)?.toInt(),
successfulCount: (json['successfulCount'] as num?)?.toInt(),
failedCount: (json['failedCount'] as num?)?.toInt(),
);
Map<String, dynamic> _$GroupBatchToJson(GroupBatch instance) =>
<String, dynamic>{
'id': instance.id,
'recipientGroupId': instance.recipientGroupId,
'userId': instance.userId,
'currency': instance.currency,
'description': instance.description,
'value': instance.value,
'status': instance.status,
'paymentProcessorLabel': instance.paymentProcessorLabel,
'paymentProcessorName': instance.paymentProcessorName,
'paymentProcessorImage': instance.paymentProcessorImage,
'transactionTemplate': instance.transactionTemplate,
'createdAt': instance.createdAt,
'count': instance.count,
'successfulCount': instance.successfulCount,
'failedCount': instance.failedCount,
};
GroupBatchItem _$GroupBatchItemFromJson(Map<String, dynamic> json) =>
GroupBatchItem(
id: json['id'] as String?,
groupBatchId: json['groupBatchId'] as String?,
recipientName: json['recipientName'] as String?,
recipientPhone: json['recipientPhone'] as String?,
amount: (json['amount'] as num?)?.toDouble(),
status: json['status'] as String?,
transactionId: json['transactionId'] as String?,
errorMessage: json['errorMessage'] as String?,
createdAt: json['createdAt'] as String?,
);
Map<String, dynamic> _$GroupBatchItemToJson(GroupBatchItem instance) =>
<String, dynamic>{
'id': instance.id,
'groupBatchId': instance.groupBatchId,
'recipientName': instance.recipientName,
'recipientPhone': instance.recipientPhone,
'amount': instance.amount,
'status': instance.status,
'transactionId': instance.transactionId,
'errorMessage': instance.errorMessage,
'createdAt': instance.createdAt,
};
CreateBatchRequest _$CreateBatchRequestFromJson(Map<String, dynamic> json) =>
CreateBatchRequest(
recipientGroupId: json['recipientGroupId'] as String,
userId: json['userId'] as String,
currency: json['currency'] as String,
description: json['description'] as String,
amount: (json['amount'] as num).toDouble(),
paymentProcessorLabel: json['paymentProcessorLabel'] as String?,
paymentProcessorName: json['paymentProcessorName'] as String?,
paymentProcessorImage: json['paymentProcessorImage'] as String?,
transactionTemplate: json['transactionTemplate'] as Map<String, dynamic>,
);
Map<String, dynamic> _$CreateBatchRequestToJson(CreateBatchRequest instance) =>
<String, dynamic>{
'recipientGroupId': instance.recipientGroupId,
'userId': instance.userId,
'currency': instance.currency,
'description': instance.description,
'amount': instance.amount,
'paymentProcessorLabel': instance.paymentProcessorLabel,
'paymentProcessorName': instance.paymentProcessorName,
'paymentProcessorImage': instance.paymentProcessorImage,
'transactionTemplate': instance.transactionTemplate,
};
BatchActionRequest _$BatchActionRequestFromJson(Map<String, dynamic> json) =>
BatchActionRequest(
userId: json['userId'] as String,
idempotencyKey: json['idempotencyKey'] as String,
);
Map<String, dynamic> _$BatchActionRequestToJson(BatchActionRequest instance) =>
<String, dynamic>{
'userId': instance.userId,
'idempotencyKey': instance.idempotencyKey,
};
GroupBatchUpdateRequest _$GroupBatchUpdateRequestFromJson(
Map<String, dynamic> json,
) => GroupBatchUpdateRequest(
id: json['id'] as String?,
userId: json['userId'] as String?,
paymentProcessorLabel: json['paymentProcessorLabel'] as String?,
paymentProcessorName: json['paymentProcessorName'] as String?,
paymentProcessorImage: json['paymentProcessorImage'] as String?,
);
Map<String, dynamic> _$GroupBatchUpdateRequestToJson(
GroupBatchUpdateRequest instance,
) => <String, dynamic>{
'id': instance.id,
'userId': instance.userId,
'paymentProcessorLabel': instance.paymentProcessorLabel,
'paymentProcessorName': instance.paymentProcessorName,
'paymentProcessorImage': instance.paymentProcessorImage,
};

View File

@@ -0,0 +1,72 @@
import 'package:json_annotation/json_annotation.dart';
import 'package:qpay/screens/recipient/recipients_controller.dart';
part 'recipient_group_model.g.dart';
@JsonSerializable(explicitToJson: true)
class RecipientGroup {
final String? id;
final String name;
final String? description;
final String? userId;
final String? createdAt;
const RecipientGroup({
this.id,
required this.name,
this.description,
this.userId,
this.createdAt,
});
factory RecipientGroup.fromJson(Map<String, dynamic> json) =>
_$RecipientGroupFromJson(json);
Map<String, dynamic> toJson() => _$RecipientGroupToJson(this);
}
@JsonSerializable(explicitToJson: true)
class RecipientGroupMember {
final String? id;
final String recipientGroupId;
final String? recipientUid;
final String? userId;
final String? account;
final String? createdAt;
final Recipient recipient;
const RecipientGroupMember({
this.id,
required this.recipientGroupId,
this.recipientUid,
this.userId,
this.account,
this.createdAt,
required this.recipient,
});
factory RecipientGroupMember.fromJson(Map<String, dynamic> json) =>
_$RecipientGroupMemberFromJson(json);
Map<String, dynamic> toJson() => _$RecipientGroupMemberToJson(this);
}
@JsonSerializable(explicitToJson: true)
class CreateGroupRequest {
final String name;
final String? description;
final String userId;
final List<Recipient> recipients;
const CreateGroupRequest({
required this.name,
this.description,
required this.userId,
required this.recipients,
});
factory CreateGroupRequest.fromJson(Map<String, dynamic> json) =>
_$CreateGroupRequestFromJson(json);
Map<String, dynamic> toJson() => _$CreateGroupRequestToJson(this);
}

View File

@@ -0,0 +1,67 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'recipient_group_model.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
RecipientGroup _$RecipientGroupFromJson(Map<String, dynamic> json) =>
RecipientGroup(
id: json['id'] as String?,
name: json['name'] as String,
description: json['description'] as String?,
userId: json['userId'] as String?,
createdAt: json['createdAt'] as String?,
);
Map<String, dynamic> _$RecipientGroupToJson(RecipientGroup instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'description': instance.description,
'userId': instance.userId,
'createdAt': instance.createdAt,
};
RecipientGroupMember _$RecipientGroupMemberFromJson(
Map<String, dynamic> json,
) => RecipientGroupMember(
id: json['id'] as String?,
recipientGroupId: json['recipientGroupId'] as String,
recipientUid: json['recipientUid'] as String?,
userId: json['userId'] as String?,
account: json['account'] as String?,
createdAt: json['createdAt'] as String?,
recipient: Recipient.fromJson(json['recipient'] as Map<String, dynamic>),
);
Map<String, dynamic> _$RecipientGroupMemberToJson(
RecipientGroupMember instance,
) => <String, dynamic>{
'id': instance.id,
'recipientGroupId': instance.recipientGroupId,
'recipientUid': instance.recipientUid,
'userId': instance.userId,
'account': instance.account,
'createdAt': instance.createdAt,
'recipient': instance.recipient.toJson(),
};
CreateGroupRequest _$CreateGroupRequestFromJson(Map<String, dynamic> json) =>
CreateGroupRequest(
name: json['name'] as String,
description: json['description'] as String?,
userId: json['userId'] as String,
recipients: (json['recipients'] as List<dynamic>)
.map((e) => Recipient.fromJson(e as Map<String, dynamic>))
.toList(),
);
Map<String, dynamic> _$CreateGroupRequestToJson(CreateGroupRequest instance) =>
<String, dynamic>{
'name': instance.name,
'description': instance.description,
'userId': instance.userId,
'recipients': instance.recipients.map((e) => e.toJson()).toList(),
};

View File

@@ -8,54 +8,37 @@ class HistoryModel {
List<Map<String, dynamic>> transactions = []; List<Map<String, dynamic>> transactions = [];
bool isLoading = false; bool isLoading = false;
bool isLoadingMore = false;
bool isActing = false;
String? errorMessage; String? errorMessage;
int currentPage = 0;
int totalPages = 0;
String searchQuery = '';
String? statusFilter;
String? typeFilter;
bool selectMode = false;
Set<String> selectedTransactionIds = {};
} }
class HistoryController extends ChangeNotifier { class HistoryController extends ChangeNotifier {
final HistoryModel model = HistoryModel(); final HistoryModel model = HistoryModel();
final Http http = Http(); final Http http = Http();
BuildContext context; BuildContext context;
bool _disposed = false;
HistoryController(this.context); HistoryController(this.context);
Future<ApiResponse<List<Map<String, dynamic>>>> getTransactions(Map<String, String> params) async { @override
model.isLoading = true; void dispose() {
if (params['page'] == '0') { _disposed = true;
model.transactions = getFakeTransactions(); super.dispose();
} }
notifyListeners();
try { void _safeNotify() {
Map<String, dynamic> response = await http.get( if (!_disposed) notifyListeners();
'/public/transaction?${buildQueryParameters(params)}',
);
if (params['page'] == '0') {
model.transactions = [];
}
model.pageableModel = PageableModel.fromJson(response);
// pagination adds to current list instead of replacing it
model.transactions =
model.transactions +
model.pageableModel!.content
.map((e) => e as Map<String, dynamic>)
.toList();
model.isLoading = false;
notifyListeners();
return ApiResponse.success(List<Map<String, dynamic>>.from(model.transactions));
} catch (e) {
logger.e(e);
model.errorMessage = e.toString();
model.transactions = [];
model.isLoading = false;
notifyListeners();
return ApiResponse.failure(
"Problem fetching transactions, are you connected to the internet?",
);
}
} }
String buildQueryParameters(Map<String, String> params) { String buildQueryParameters(Map<String, String> params) {
@@ -70,6 +53,152 @@ class HistoryController extends ChangeNotifier {
return query.substring(0, query.length - 1); return query.substring(0, query.length - 1);
} }
Future<ApiResponse<List<Map<String, dynamic>>>> getTransactions(
Map<String, String> params,
) async {
final isFirstPage = params['page'] == '0' || params['page'] == null;
if (isFirstPage) {
model.isLoading = true;
model.transactions = [];
model.currentPage = 0;
} else {
model.isLoadingMore = true;
}
_safeNotify();
params['partyType'] = 'INDIVIDUAL';
try {
Map<String, dynamic> response = await http.get(
'/public/transaction?${buildQueryParameters(params)}',
);
model.pageableModel = PageableModel.fromJson(response);
model.currentPage = model.pageableModel!.number;
model.totalPages = model.pageableModel!.totalPages;
final newItems = model.pageableModel!.content
.map((e) => Map<String, dynamic>.from(e as Map))
.toList();
if (isFirstPage) {
model.transactions = newItems;
} else {
model.transactions.addAll(newItems);
}
model.isLoading = false;
model.isLoadingMore = false;
_safeNotify();
return ApiResponse.success(model.transactions);
} catch (e) {
logger.e(e);
model.errorMessage = e.toString();
if (isFirstPage) {
model.transactions = [];
}
model.isLoading = false;
model.isLoadingMore = false;
_safeNotify();
return ApiResponse.failure(
"Problem fetching transactions, are you connected to the internet?",
);
}
}
Future<void> searchTransactions(
String userId, {
String? search,
String? status,
String? type,
}) async {
model.searchQuery = search ?? '';
model.statusFilter = status;
model.typeFilter = type;
final params = <String, String>{
'userId': userId,
'page': '0',
'size': '20',
'sort': 'createdAt,desc',
};
if (search != null && search.isNotEmpty) {
params['creditAccount'] = search;
params['creditEmail'] = search;
params['creditPhone'] = search;
params['creditName'] = search;
params['billName'] = search;
params['amount'] = search;
}
if (status != null && status.isNotEmpty) {
params['status'] = status;
}
if (type != null && type.isNotEmpty) {
params['type'] = type;
}
await getTransactions(params);
}
Future<void> loadNextPage(String userId) async {
if (model.isLoadingMore || model.currentPage + 1 >= model.totalPages) {
return;
}
final params = <String, String>{
'userId': userId,
'page': (model.currentPage + 1).toString(),
'size': '20',
'sort': 'createdAt,desc',
};
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['amount'] = model.searchQuery;
}
if (model.statusFilter != null && model.statusFilter!.isNotEmpty) {
params['status'] = model.statusFilter!;
}
if (model.typeFilter != null && model.typeFilter!.isNotEmpty) {
params['type'] = model.typeFilter!;
}
await getTransactions(params);
}
void toggleSelectMode() {
model.selectMode = !model.selectMode;
if (!model.selectMode) {
model.selectedTransactionIds.clear();
}
_safeNotify();
}
void toggleTransactionSelection(String id) {
if (id.isEmpty) return;
if (model.selectedTransactionIds.contains(id)) {
model.selectedTransactionIds.remove(id);
} else {
model.selectedTransactionIds.add(id);
}
_safeNotify();
}
/// Removes selected transactions from the local list. The history
/// endpoint does not support deletes, so this is a client-side action.
void deleteSelectedTransactions() {
final ids = List<String>.from(model.selectedTransactionIds);
model.transactions.removeWhere((t) => ids.contains(t['id'] as String?));
model.selectMode = false;
model.selectedTransactionIds.clear();
_safeNotify();
}
void clearFilters() {
model.statusFilter = null;
model.typeFilter = null;
_safeNotify();
}
List<Map<String, dynamic>> getFakeTransactions() { List<Map<String, dynamic>> getFakeTransactions() {
return [ return [
{ {

View File

@@ -6,6 +6,7 @@ import 'package:qpay/screens/receipt/receipt_controller.dart';
import 'package:qpay/screens/transaction_controller.dart'; import 'package:qpay/screens/transaction_controller.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:qpay/screens/history/history_controller.dart'; import 'package:qpay/screens/history/history_controller.dart';
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:qpay/screens/transactions/transaction_model.dart' as txn; import 'package:qpay/screens/transactions/transaction_model.dart' as txn;
@@ -20,39 +21,61 @@ class _HistoryScreenState extends State<HistoryScreen>
with TickerProviderStateMixin { with TickerProviderStateMixin {
late HistoryController controller; late HistoryController controller;
late SharedPreferences prefs; late SharedPreferences prefs;
late AnimationController _controller;
late TransactionController transactionController; late TransactionController transactionController;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final Map<String, String> _queryParams = {}; final ScrollController _scrollController = ScrollController();
final int _pageSize = 8; late final AnimationController _entryController;
late final Animation<double> _entryFade;
late final Animation<Offset> _entrySlide;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
controller = HistoryController(context); controller = HistoryController(context);
setupData();
transactionController = Provider.of<TransactionController>( transactionController = Provider.of<TransactionController>(
context, context,
listen: false, listen: false,
); );
_controller = AnimationController( // Short, subtle entry animation (replaces the old 3-second stagger)
duration: const Duration(milliseconds: 3000), _entryController = AnimationController(
duration: const Duration(milliseconds: 220),
vsync: this, vsync: this,
)..forward(); );
_entryFade = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(parent: _entryController, curve: Curves.easeOut));
_entrySlide = Tween<Offset>(
begin: const Offset(0, 0.02),
end: Offset.zero,
).animate(CurvedAnimation(parent: _entryController, curve: Curves.easeOut));
_entryController.forward();
// Listen to controller changes to rebuild when transactions are loaded
controller.addListener(() => setState(() {}));
_searchController.addListener(() => setState(() {}));
_scrollController.addListener(_onScroll);
setupData();
} }
void setupData() async { Future<void> setupData() async {
prefs = await SharedPreferences.getInstance(); prefs = await SharedPreferences.getInstance();
final userId = prefs.getString("userId") ?? '';
await controller.searchTransactions(userId);
}
await controller.getTransactions({ void _onScroll() {
'userId': prefs.getString("userId")!, if (_scrollController.position.pixels >=
'page': '0', _scrollController.position.maxScrollExtent - 200) {
'size': _pageSize.toString(), final userId = prefs.getString("userId") ?? '';
'sort': 'createdAt,desc', if (userId.isNotEmpty) {
}); controller.loadNextPage(userId);
}
}
} }
/// Handles the repeat action for a transaction. /// Handles the repeat action for a transaction.
@@ -60,10 +83,178 @@ class _HistoryScreenState extends State<HistoryScreen>
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);
if (mounted) {
context.push('/make-payment');
}
}
Future<void> _onDeleteSelected() async {
final count = controller.model.selectedTransactionIds.length;
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete transactions'),
content: Text('Remove $count transaction(s) from this list?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Delete'),
),
],
),
);
if (confirmed == true) {
controller.deleteSelectedTransactions();
if (mounted) {
AppSnackBar.show(
context,
'Transactions removed from this list',
duration: const Duration(seconds: 2),
);
}
}
}
void _showFilterSheet() {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) {
return StatefulBuilder(
builder: (context, setSheetState) {
return Padding(
padding: const EdgeInsets.all(20),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'Filter Transactions',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
TextButton(
onPressed: () {
setSheetState(() {
controller.model.statusFilter = null;
controller.model.typeFilter = null;
});
},
child: const Text('Clear All'),
),
],
),
const SizedBox(height: 16),
DropdownButtonFormField<String?>(
initialValue: controller.model.statusFilter,
decoration: InputDecoration(
labelText: 'Status',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
items: const [
DropdownMenuItem(value: null, child: Text('All')),
DropdownMenuItem(
value: 'SUCCESS',
child: Text('Success'),
),
DropdownMenuItem(
value: 'PENDING',
child: Text('Pending'),
),
DropdownMenuItem(value: 'FAILED', child: Text('Failed')),
],
onChanged: (v) {
setSheetState(() => controller.model.statusFilter = v);
},
),
const SizedBox(height: 16),
DropdownButtonFormField<String?>(
initialValue: controller.model.typeFilter,
decoration: InputDecoration(
labelText: 'Type',
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 14,
),
),
items: const [
DropdownMenuItem(value: null, child: Text('All')),
DropdownMenuItem(
value: 'REQUEST',
child: Text('Request'),
),
DropdownMenuItem(
value: 'PAYMENT',
child: Text('Payment'),
),
DropdownMenuItem(value: 'REFUND', child: Text('Refund')),
],
onChanged: (v) {
setSheetState(() => controller.model.typeFilter = v);
},
),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
Navigator.of(ctx).pop();
final userId = prefs.getString("userId") ?? '';
controller.searchTransactions(
userId,
search: _searchController.text.isNotEmpty
? _searchController.text
: null,
status: controller.model.statusFilter,
type: controller.model.typeFilter,
);
},
child: const Text('Apply Filters'),
),
),
const SizedBox(height: 8),
],
),
);
},
);
},
);
} }
@override @override
void dispose() { void dispose() {
_searchController.dispose();
_scrollController.dispose();
_entryController.dispose();
controller.dispose(); controller.dispose();
super.dispose(); super.dispose();
} }
@@ -71,96 +262,309 @@ class _HistoryScreenState extends State<HistoryScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
body: CustomScrollView( appBar: AppBar(title: const Text('History'), centerTitle: true),
slivers: [ body: Column(
SliverAppBar( children: [
title: Text( Expanded(
'History', child: CustomScrollView(
style: TextStyle(fontSize: 20, color: Colors.black), controller: _scrollController,
), slivers: [
centerTitle: true, SliverToBoxAdapter(
), child: FadeTransition(
SliverToBoxAdapter( opacity: _entryFade,
child: ListenableBuilder( child: SlideTransition(
listenable: controller, position: _entrySlide,
builder: (context, child) { child: Center(
return Container( child: LayoutBuilder(
padding: const EdgeInsets.all(16), builder: (context, constraints) {
child: Center( final width =
child: LayoutBuilder( constraints.maxWidth > ResponsivePolicy.md
builder: (context, constraints) { ? ResponsivePolicy.md.toDouble()
return SizedBox( : constraints.maxWidth;
width: double.parse( return SizedBox(
constraints.maxWidth > ResponsivePolicy.md width: width,
? ResponsivePolicy.md.toString() child: Padding(
: constraints.maxWidth.toString(), padding: const EdgeInsets.all(16),
), child: Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildSearchField(controller),
SizedBox(height: 10),
if (controller.model.transactions.isEmpty)
Column(
children: [ children: [
SizedBox(height: 30), _buildSearchRow(),
Text( const SizedBox(height: 12),
'No transactions yet. Make a payment to see your recent activity.', _buildContent(),
style: TextStyle(fontSize: 16),
textAlign: TextAlign.center,
),
], ],
), ),
if (controller.model.transactions.isNotEmpty) ),
Column( );
children: [ },
ListView( ),
shrinkWrap: true, ),
physics:
const NeverScrollableScrollPhysics(),
children: [
...controller.model.transactions.map(
(e) =>
_buildTransactionItem(e, context),
),
],
),
SizedBox(height: 10),
if (controller.model.pageableModel !=
null &&
controller.model.pageableModel!.number <
controller
.model
.pageableModel!
.totalPages)
OutlinedButton(
child: Text('Load more'),
onPressed: () {
controller.getTransactions({
'userId': prefs.getString(
"userId",
)!,
'page':
(controller
.model
.pageableModel!
.number +
1)
.toString(),
'size': _pageSize.toString(),
'sort': 'createdAt,desc',
});
},
),
],
),
],
),
);
},
), ),
), ),
); ),
}, ],
),
),
if (controller.model.selectMode) _buildSelectionBottomBar(),
],
),
);
}
Widget _buildSearchRow() {
final hasFilters =
controller.model.statusFilter != null ||
controller.model.typeFilter != null;
return Row(
children: [
Expanded(
child: TextFormField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search by name, account, email, phone',
prefixIcon: const Icon(Icons.search),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
final userId = prefs.getString("userId") ?? '';
controller.searchTransactions(
userId,
status: controller.model.statusFilter,
type: controller.model.typeFilter,
);
},
)
: null,
filled: true,
fillColor: Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
),
),
),
textInputAction: TextInputAction.search,
onFieldSubmitted: (value) {
final userId = prefs.getString("userId") ?? '';
controller.searchTransactions(
userId,
search: value.isNotEmpty ? value : null,
status: controller.model.statusFilter,
type: controller.model.typeFilter,
);
},
),
),
const SizedBox(width: 8),
_buildIconButton(
icon: Icons.filter_list,
tooltip: 'Filter transactions',
isActive: hasFilters,
onPressed: _showFilterSheet,
),
const SizedBox(width: 8),
_buildIconButton(
icon: controller.model.selectMode
? Icons.checklist
: Icons.checklist_outlined,
tooltip: 'Select transactions',
isActive: controller.model.selectMode,
onPressed: controller.toggleSelectMode,
),
const SizedBox(width: 8),
_buildIconButton(
icon: Icons.refresh,
tooltip: 'Refresh transactions',
isActive: false,
onPressed: () async {
await setupData();
if (mounted) {
AppSnackBar.show(
context,
'Transactions refreshed',
duration: const Duration(seconds: 1),
);
}
},
),
const SizedBox(width: 8),
_buildIconButton(
icon: Icons.add,
tooltip: 'New payment',
isActive: false,
onPressed: () => context.push('/recipients'),
),
],
);
}
Widget _buildIconButton({
required IconData icon,
required String tooltip,
required bool isActive,
required VoidCallback onPressed,
}) {
return Container(
decoration: BoxDecoration(
color: isActive
? Theme.of(context).colorScheme.primary
: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
),
child: IconButton(
icon: Icon(icon, color: isActive ? Colors.white : Colors.grey.shade700),
tooltip: tooltip,
onPressed: onPressed,
),
);
}
Widget _buildContent() {
if (controller.model.isLoading) {
return const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 60),
child: CircularProgressIndicator(),
),
);
}
if (controller.model.transactions.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 40),
child: Column(
children: [
Icon(
Icons.receipt_long_outlined,
size: 64,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
const Text(
'No transactions yet.',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
Text(
'Tap + to make a new payment.',
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
),
],
),
),
);
}
final hasFilters =
controller.model.statusFilter != null ||
controller.model.typeFilter != null;
return Column(
children: [
if (controller.model.selectMode)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Text(
'${controller.model.selectedTransactionIds.length} selected',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
color: Colors.grey.shade600,
),
),
],
),
),
if (hasFilters)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Wrap(
spacing: 6,
runSpacing: 6,
children: [
if (controller.model.statusFilter != null)
_activeFilterChip(
'Status: ${controller.model.statusFilter}',
() {
controller.model.statusFilter = null;
final userId = prefs.getString("userId") ?? '';
controller.searchTransactions(
userId,
search: _searchController.text.isNotEmpty
? _searchController.text
: null,
type: controller.model.typeFilter,
);
},
),
if (controller.model.typeFilter != null)
_activeFilterChip('Type: ${controller.model.typeFilter}', () {
controller.model.typeFilter = null;
final userId = prefs.getString("userId") ?? '';
controller.searchTransactions(
userId,
search: _searchController.text.isNotEmpty
? _searchController.text
: null,
status: controller.model.statusFilter,
);
}),
],
),
),
...controller.model.transactions.map(
(e) => _buildTransactionItem(e, context),
),
if (controller.model.isLoadingMore)
const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
),
],
);
}
Widget _activeFilterChip(String label, VoidCallback onRemove) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withAlpha(20),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Theme.of(context).colorScheme.primary.withAlpha(60),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
style: TextStyle(
fontSize: 12,
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: 4),
InkWell(
onTap: onRemove,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.all(2),
child: Icon(
Icons.close,
size: 14,
color: Theme.of(context).colorScheme.primary,
),
), ),
), ),
], ],
@@ -168,102 +572,108 @@ class _HistoryScreenState extends State<HistoryScreen>
); );
} }
Widget _buildSearchField(HistoryController controller) { Widget _buildTransactionItem(Map<String, dynamic> e, BuildContext context) {
return Column( final id = e['id'] as String? ?? '';
crossAxisAlignment: CrossAxisAlignment.start, final isSelected = controller.model.selectedTransactionIds.contains(id);
children: [ final inSelectMode = controller.model.selectMode;
Row(
children: [
Expanded(
child: TextFormField(
textInputAction: TextInputAction.search,
onFieldSubmitted: (value) {
_queryParams.clear();
setState(() {
_queryParams['creditAccount'] = _searchController.text;
_queryParams['creditEmail'] = _searchController.text;
_queryParams['creditPhone'] = _searchController.text;
_queryParams['creditName'] = _searchController.text;
_queryParams['billName'] = _searchController.text;
_queryParams['amount'] = _searchController.text;
_queryParams['userId'] = prefs.getString("userId")!;
controller.getTransactions(_queryParams); return Stack(
}); children: [
TransactionItemWidget(
transaction: e,
enabled: controller.model.isLoading,
onRepeat: inSelectMode
? null
: () async {
await _repeatTransaction(e);
}, },
controller: _searchController, ),
keyboardType: TextInputType.text, if (inSelectMode)
decoration: InputDecoration( Positioned.fill(
hintText: 'Search by name, account, email, phone', child: Material(
focusedErrorBorder: OutlineInputBorder( color: Colors.transparent,
borderRadius: BorderRadius.circular(12), child: InkWell(
borderSide: BorderSide( borderRadius: BorderRadius.circular(16),
color: Theme.of(context).colorScheme.secondary, onTap: () => controller.toggleTransactionSelection(id),
child: Container(
decoration: BoxDecoration(
color: isSelected
? Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.08)
: Colors.transparent,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.transparent,
width: 2,
), ),
), ),
enabledBorder: OutlineInputBorder( child: Align(
borderRadius: BorderRadius.circular(12), alignment: Alignment.topLeft,
borderSide: BorderSide( child: Padding(
color: Theme.of( padding: const EdgeInsets.all(8),
context, child: Icon(
).colorScheme.primary.withValues(alpha: 0.2), isSelected
? Icons.check_box
: Icons.check_box_outline_blank,
size: 22,
color: isSelected
? Theme.of(context).colorScheme.primary
: Colors.grey.shade500,
),
), ),
), ),
suffixIcon: _searchController.text.isNotEmpty
? IconButton(
onPressed: () {
_searchController.clear();
_queryParams.clear();
controller.getTransactions({
'userId': prefs.getString("userId")!,
});
},
icon: Icon(Icons.clear),
color: Theme.of(context).colorScheme.primary,
)
: null,
), ),
onChanged: (value) {
// controller.updateCreditAccount(value);
},
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter an amount';
}
if (double.tryParse(value) == null) {
return 'Please enter a valid amount';
}
return null;
},
), ),
), ),
], ),
),
], ],
); );
} }
Widget _buildTransactionItem(Map<String, dynamic> e, BuildContext context) { Widget _buildSelectionBottomBar() {
int index = controller.model.transactions.indexOf(e); final selectedCount = controller.model.selectedTransactionIds.length;
Animation<Offset> animation = return Container(
Tween<Offset>(begin: Offset(0, -0.25), end: Offset.zero).animate( decoration: BoxDecoration(
CurvedAnimation( color: Colors.white,
parent: _controller, boxShadow: [
curve: Interval(0.175 * index, 1.0, curve: Curves.easeIn), BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 8,
offset: const Offset(0, -2),
), ),
); ],
),
return SlideTransition( child: SafeArea(
position: animation, child: Padding(
child: TransactionItemWidget( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
transaction: e, child: Row(
enabled: controller.model.isLoading, children: [
onRepeat: () async { Text(
await _repeatTransaction(e); '$selectedCount selected',
if (context.mounted) { style: const TextStyle(
context.push('/make-payment'); fontSize: 14,
} fontWeight: FontWeight.w500,
}, ),
),
const Spacer(),
TextButton.icon(
onPressed: () => controller.toggleSelectMode(),
icon: const Icon(Icons.close, size: 20),
label: const Text('Cancel'),
),
const SizedBox(width: 4),
TextButton.icon(
onPressed: selectedCount > 0 ? _onDeleteSelected : null,
icon: const Icon(Icons.delete_outline, size: 20),
label: const Text('Delete'),
style: TextButton.styleFrom(foregroundColor: Colors.red),
),
],
),
),
), ),
); );
} }

View File

@@ -58,6 +58,7 @@ abstract class Category with _$Category {
@freezed @freezed
abstract class BillProvider with _$BillProvider { abstract class BillProvider with _$BillProvider {
const factory BillProvider({ const factory BillProvider({
required String id,
required String clientId, required String clientId,
required String name, required String name,
required String description, required String description,
@@ -100,7 +101,7 @@ class HomeController extends ChangeNotifier {
return ApiResponse.success(model.accounts); return ApiResponse.success(model.accounts);
} }
Future<ApiResponse<List<BillProvider>>> getProviders() async { Future<ApiResponse<List<BillProvider>>> getProviders(String currency) async {
try { try {
model.filterableProviders = getFakeProviders(); model.filterableProviders = getFakeProviders();
model.transactions = getFakeTransactions(); model.transactions = getFakeTransactions();
@@ -108,7 +109,9 @@ class HomeController extends ChangeNotifier {
model.isLoading = true; model.isLoading = true;
if (!_disposed) notifyListeners(); if (!_disposed) notifyListeners();
final response = await http.get('/public/providers?sort=priority,asc'); final response = await http.get(
'/public/providers?currency=$currency&sort=priority,asc',
);
List<BillProvider> providers = PageableModel.fromJson(response).content List<BillProvider> providers = PageableModel.fromJson(response).content
.map((e) => BillProvider.fromJson(e as Map<String, dynamic>)) .map((e) => BillProvider.fromJson(e as Map<String, dynamic>))
.toList(); .toList();
@@ -152,7 +155,7 @@ class HomeController extends ChangeNotifier {
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&userId=$userId', '/public/transaction?sort=createdAt,desc&size=3&page=0&partyType=INDIVIDUAL&userId=$userId',
); );
PageableModel pageableModel = PageableModel.fromJson(response); PageableModel pageableModel = PageableModel.fromJson(response);
@@ -258,6 +261,7 @@ class HomeController extends ChangeNotifier {
List<BillProvider> getFakeProviders() { List<BillProvider> getFakeProviders() {
return [ return [
BillProvider( BillProvider(
id: '1',
clientId: 'econet_airtime', clientId: 'econet_airtime',
name: 'Econet Airtime', name: 'Econet Airtime',
description: 'Econet Airtime', description: 'Econet Airtime',

View File

@@ -287,7 +287,7 @@ as String,
/// @nodoc /// @nodoc
mixin _$BillProvider { mixin _$BillProvider {
String get clientId; String get name; String get description; bool get requiresAccount; bool get requiresAmount; bool? get requiresAmountFromMerchant; bool get requiresPhone; bool? get requiresReversal; String? get additionalDataString; String? get processorType; String get uid; String get image; String get label; String get category; String? get accountFieldName; String get id; String get clientId; String get name; String get description; bool get requiresAccount; bool get requiresAmount; bool? get requiresAmountFromMerchant; bool get requiresPhone; bool? get requiresReversal; String? get additionalDataString; String? get processorType; String get uid; String get image; String get label; String get category; String? get accountFieldName;
/// Create a copy of BillProvider /// Create a copy of BillProvider
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -300,16 +300,16 @@ $BillProviderCopyWith<BillProvider> get copyWith => _$BillProviderCopyWithImpl<B
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is BillProvider&&(identical(other.clientId, clientId) || other.clientId == clientId)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.requiresAccount, requiresAccount) || other.requiresAccount == requiresAccount)&&(identical(other.requiresAmount, requiresAmount) || other.requiresAmount == requiresAmount)&&(identical(other.requiresAmountFromMerchant, requiresAmountFromMerchant) || other.requiresAmountFromMerchant == requiresAmountFromMerchant)&&(identical(other.requiresPhone, requiresPhone) || other.requiresPhone == requiresPhone)&&(identical(other.requiresReversal, requiresReversal) || other.requiresReversal == requiresReversal)&&(identical(other.additionalDataString, additionalDataString) || other.additionalDataString == additionalDataString)&&(identical(other.processorType, processorType) || other.processorType == processorType)&&(identical(other.uid, uid) || other.uid == uid)&&(identical(other.image, image) || other.image == image)&&(identical(other.label, label) || other.label == label)&&(identical(other.category, category) || other.category == category)&&(identical(other.accountFieldName, accountFieldName) || other.accountFieldName == accountFieldName)); return identical(this, other) || (other.runtimeType == runtimeType&&other is BillProvider&&(identical(other.id, id) || other.id == id)&&(identical(other.clientId, clientId) || other.clientId == clientId)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.requiresAccount, requiresAccount) || other.requiresAccount == requiresAccount)&&(identical(other.requiresAmount, requiresAmount) || other.requiresAmount == requiresAmount)&&(identical(other.requiresAmountFromMerchant, requiresAmountFromMerchant) || other.requiresAmountFromMerchant == requiresAmountFromMerchant)&&(identical(other.requiresPhone, requiresPhone) || other.requiresPhone == requiresPhone)&&(identical(other.requiresReversal, requiresReversal) || other.requiresReversal == requiresReversal)&&(identical(other.additionalDataString, additionalDataString) || other.additionalDataString == additionalDataString)&&(identical(other.processorType, processorType) || other.processorType == processorType)&&(identical(other.uid, uid) || other.uid == uid)&&(identical(other.image, image) || other.image == image)&&(identical(other.label, label) || other.label == label)&&(identical(other.category, category) || other.category == category)&&(identical(other.accountFieldName, accountFieldName) || other.accountFieldName == accountFieldName));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,clientId,name,description,requiresAccount,requiresAmount,requiresAmountFromMerchant,requiresPhone,requiresReversal,additionalDataString,processorType,uid,image,label,category,accountFieldName); int get hashCode => Object.hash(runtimeType,id,clientId,name,description,requiresAccount,requiresAmount,requiresAmountFromMerchant,requiresPhone,requiresReversal,additionalDataString,processorType,uid,image,label,category,accountFieldName);
@override @override
String toString() { String toString() {
return 'BillProvider(clientId: $clientId, name: $name, description: $description, requiresAccount: $requiresAccount, requiresAmount: $requiresAmount, requiresAmountFromMerchant: $requiresAmountFromMerchant, requiresPhone: $requiresPhone, requiresReversal: $requiresReversal, additionalDataString: $additionalDataString, processorType: $processorType, uid: $uid, image: $image, label: $label, category: $category, accountFieldName: $accountFieldName)'; return 'BillProvider(id: $id, clientId: $clientId, name: $name, description: $description, requiresAccount: $requiresAccount, requiresAmount: $requiresAmount, requiresAmountFromMerchant: $requiresAmountFromMerchant, requiresPhone: $requiresPhone, requiresReversal: $requiresReversal, additionalDataString: $additionalDataString, processorType: $processorType, uid: $uid, image: $image, label: $label, category: $category, accountFieldName: $accountFieldName)';
} }
@@ -320,7 +320,7 @@ abstract mixin class $BillProviderCopyWith<$Res> {
factory $BillProviderCopyWith(BillProvider value, $Res Function(BillProvider) _then) = _$BillProviderCopyWithImpl; factory $BillProviderCopyWith(BillProvider value, $Res Function(BillProvider) _then) = _$BillProviderCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
String clientId, String name, String description, bool requiresAccount, bool requiresAmount, bool? requiresAmountFromMerchant, bool requiresPhone, bool? requiresReversal, String? additionalDataString, String? processorType, String uid, String image, String label, String category, String? accountFieldName String id, String clientId, String name, String description, bool requiresAccount, bool requiresAmount, bool? requiresAmountFromMerchant, bool requiresPhone, bool? requiresReversal, String? additionalDataString, String? processorType, String uid, String image, String label, String category, String? accountFieldName
}); });
@@ -337,9 +337,10 @@ class _$BillProviderCopyWithImpl<$Res>
/// Create a copy of BillProvider /// Create a copy of BillProvider
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? clientId = null,Object? name = null,Object? description = null,Object? requiresAccount = null,Object? requiresAmount = null,Object? requiresAmountFromMerchant = freezed,Object? requiresPhone = null,Object? requiresReversal = freezed,Object? additionalDataString = freezed,Object? processorType = freezed,Object? uid = null,Object? image = null,Object? label = null,Object? category = null,Object? accountFieldName = freezed,}) { @pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? clientId = null,Object? name = null,Object? description = null,Object? requiresAccount = null,Object? requiresAmount = null,Object? requiresAmountFromMerchant = freezed,Object? requiresPhone = null,Object? requiresReversal = freezed,Object? additionalDataString = freezed,Object? processorType = freezed,Object? uid = null,Object? image = null,Object? label = null,Object? category = null,Object? accountFieldName = freezed,}) {
return _then(_self.copyWith( return _then(_self.copyWith(
clientId: null == clientId ? _self.clientId : clientId // ignore: cast_nullable_to_non_nullable id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,clientId: null == clientId ? _self.clientId : clientId // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String,requiresAccount: null == requiresAccount ? _self.requiresAccount : requiresAccount // ignore: cast_nullable_to_non_nullable as String,requiresAccount: null == requiresAccount ? _self.requiresAccount : requiresAccount // ignore: cast_nullable_to_non_nullable
@@ -439,10 +440,10 @@ return $default(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String clientId, String name, String description, bool requiresAccount, bool requiresAmount, bool? requiresAmountFromMerchant, bool requiresPhone, bool? requiresReversal, String? additionalDataString, String? processorType, String uid, String image, String label, String category, String? accountFieldName)? $default,{required TResult orElse(),}) {final _that = this; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String clientId, String name, String description, bool requiresAccount, bool requiresAmount, bool? requiresAmountFromMerchant, bool requiresPhone, bool? requiresReversal, String? additionalDataString, String? processorType, String uid, String image, String label, String category, String? accountFieldName)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) { switch (_that) {
case _BillProvider() when $default != null: case _BillProvider() when $default != null:
return $default(_that.clientId,_that.name,_that.description,_that.requiresAccount,_that.requiresAmount,_that.requiresAmountFromMerchant,_that.requiresPhone,_that.requiresReversal,_that.additionalDataString,_that.processorType,_that.uid,_that.image,_that.label,_that.category,_that.accountFieldName);case _: return $default(_that.id,_that.clientId,_that.name,_that.description,_that.requiresAccount,_that.requiresAmount,_that.requiresAmountFromMerchant,_that.requiresPhone,_that.requiresReversal,_that.additionalDataString,_that.processorType,_that.uid,_that.image,_that.label,_that.category,_that.accountFieldName);case _:
return orElse(); return orElse();
} }
@@ -460,10 +461,10 @@ return $default(_that.clientId,_that.name,_that.description,_that.requiresAccoun
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String clientId, String name, String description, bool requiresAccount, bool requiresAmount, bool? requiresAmountFromMerchant, bool requiresPhone, bool? requiresReversal, String? additionalDataString, String? processorType, String uid, String image, String label, String category, String? accountFieldName) $default,) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String clientId, String name, String description, bool requiresAccount, bool requiresAmount, bool? requiresAmountFromMerchant, bool requiresPhone, bool? requiresReversal, String? additionalDataString, String? processorType, String uid, String image, String label, String category, String? accountFieldName) $default,) {final _that = this;
switch (_that) { switch (_that) {
case _BillProvider(): case _BillProvider():
return $default(_that.clientId,_that.name,_that.description,_that.requiresAccount,_that.requiresAmount,_that.requiresAmountFromMerchant,_that.requiresPhone,_that.requiresReversal,_that.additionalDataString,_that.processorType,_that.uid,_that.image,_that.label,_that.category,_that.accountFieldName);case _: return $default(_that.id,_that.clientId,_that.name,_that.description,_that.requiresAccount,_that.requiresAmount,_that.requiresAmountFromMerchant,_that.requiresPhone,_that.requiresReversal,_that.additionalDataString,_that.processorType,_that.uid,_that.image,_that.label,_that.category,_that.accountFieldName);case _:
throw StateError('Unexpected subclass'); throw StateError('Unexpected subclass');
} }
@@ -480,10 +481,10 @@ return $default(_that.clientId,_that.name,_that.description,_that.requiresAccoun
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String clientId, String name, String description, bool requiresAccount, bool requiresAmount, bool? requiresAmountFromMerchant, bool requiresPhone, bool? requiresReversal, String? additionalDataString, String? processorType, String uid, String image, String label, String category, String? accountFieldName)? $default,) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String clientId, String name, String description, bool requiresAccount, bool requiresAmount, bool? requiresAmountFromMerchant, bool requiresPhone, bool? requiresReversal, String? additionalDataString, String? processorType, String uid, String image, String label, String category, String? accountFieldName)? $default,) {final _that = this;
switch (_that) { switch (_that) {
case _BillProvider() when $default != null: case _BillProvider() when $default != null:
return $default(_that.clientId,_that.name,_that.description,_that.requiresAccount,_that.requiresAmount,_that.requiresAmountFromMerchant,_that.requiresPhone,_that.requiresReversal,_that.additionalDataString,_that.processorType,_that.uid,_that.image,_that.label,_that.category,_that.accountFieldName);case _: return $default(_that.id,_that.clientId,_that.name,_that.description,_that.requiresAccount,_that.requiresAmount,_that.requiresAmountFromMerchant,_that.requiresPhone,_that.requiresReversal,_that.additionalDataString,_that.processorType,_that.uid,_that.image,_that.label,_that.category,_that.accountFieldName);case _:
return null; return null;
} }
@@ -495,9 +496,10 @@ return $default(_that.clientId,_that.name,_that.description,_that.requiresAccoun
@JsonSerializable() @JsonSerializable()
class _BillProvider implements BillProvider { class _BillProvider implements BillProvider {
const _BillProvider({required this.clientId, required this.name, required this.description, required this.requiresAccount, required this.requiresAmount, required this.requiresAmountFromMerchant, required this.requiresPhone, required this.requiresReversal, required this.additionalDataString, required this.processorType, required this.uid, required this.image, required this.label, required this.category, required this.accountFieldName}); const _BillProvider({required this.id, required this.clientId, required this.name, required this.description, required this.requiresAccount, required this.requiresAmount, required this.requiresAmountFromMerchant, required this.requiresPhone, required this.requiresReversal, required this.additionalDataString, required this.processorType, required this.uid, required this.image, required this.label, required this.category, required this.accountFieldName});
factory _BillProvider.fromJson(Map<String, dynamic> json) => _$BillProviderFromJson(json); factory _BillProvider.fromJson(Map<String, dynamic> json) => _$BillProviderFromJson(json);
@override final String id;
@override final String clientId; @override final String clientId;
@override final String name; @override final String name;
@override final String description; @override final String description;
@@ -527,16 +529,16 @@ Map<String, dynamic> toJson() {
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _BillProvider&&(identical(other.clientId, clientId) || other.clientId == clientId)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.requiresAccount, requiresAccount) || other.requiresAccount == requiresAccount)&&(identical(other.requiresAmount, requiresAmount) || other.requiresAmount == requiresAmount)&&(identical(other.requiresAmountFromMerchant, requiresAmountFromMerchant) || other.requiresAmountFromMerchant == requiresAmountFromMerchant)&&(identical(other.requiresPhone, requiresPhone) || other.requiresPhone == requiresPhone)&&(identical(other.requiresReversal, requiresReversal) || other.requiresReversal == requiresReversal)&&(identical(other.additionalDataString, additionalDataString) || other.additionalDataString == additionalDataString)&&(identical(other.processorType, processorType) || other.processorType == processorType)&&(identical(other.uid, uid) || other.uid == uid)&&(identical(other.image, image) || other.image == image)&&(identical(other.label, label) || other.label == label)&&(identical(other.category, category) || other.category == category)&&(identical(other.accountFieldName, accountFieldName) || other.accountFieldName == accountFieldName)); return identical(this, other) || (other.runtimeType == runtimeType&&other is _BillProvider&&(identical(other.id, id) || other.id == id)&&(identical(other.clientId, clientId) || other.clientId == clientId)&&(identical(other.name, name) || other.name == name)&&(identical(other.description, description) || other.description == description)&&(identical(other.requiresAccount, requiresAccount) || other.requiresAccount == requiresAccount)&&(identical(other.requiresAmount, requiresAmount) || other.requiresAmount == requiresAmount)&&(identical(other.requiresAmountFromMerchant, requiresAmountFromMerchant) || other.requiresAmountFromMerchant == requiresAmountFromMerchant)&&(identical(other.requiresPhone, requiresPhone) || other.requiresPhone == requiresPhone)&&(identical(other.requiresReversal, requiresReversal) || other.requiresReversal == requiresReversal)&&(identical(other.additionalDataString, additionalDataString) || other.additionalDataString == additionalDataString)&&(identical(other.processorType, processorType) || other.processorType == processorType)&&(identical(other.uid, uid) || other.uid == uid)&&(identical(other.image, image) || other.image == image)&&(identical(other.label, label) || other.label == label)&&(identical(other.category, category) || other.category == category)&&(identical(other.accountFieldName, accountFieldName) || other.accountFieldName == accountFieldName));
} }
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@override @override
int get hashCode => Object.hash(runtimeType,clientId,name,description,requiresAccount,requiresAmount,requiresAmountFromMerchant,requiresPhone,requiresReversal,additionalDataString,processorType,uid,image,label,category,accountFieldName); int get hashCode => Object.hash(runtimeType,id,clientId,name,description,requiresAccount,requiresAmount,requiresAmountFromMerchant,requiresPhone,requiresReversal,additionalDataString,processorType,uid,image,label,category,accountFieldName);
@override @override
String toString() { String toString() {
return 'BillProvider(clientId: $clientId, name: $name, description: $description, requiresAccount: $requiresAccount, requiresAmount: $requiresAmount, requiresAmountFromMerchant: $requiresAmountFromMerchant, requiresPhone: $requiresPhone, requiresReversal: $requiresReversal, additionalDataString: $additionalDataString, processorType: $processorType, uid: $uid, image: $image, label: $label, category: $category, accountFieldName: $accountFieldName)'; return 'BillProvider(id: $id, clientId: $clientId, name: $name, description: $description, requiresAccount: $requiresAccount, requiresAmount: $requiresAmount, requiresAmountFromMerchant: $requiresAmountFromMerchant, requiresPhone: $requiresPhone, requiresReversal: $requiresReversal, additionalDataString: $additionalDataString, processorType: $processorType, uid: $uid, image: $image, label: $label, category: $category, accountFieldName: $accountFieldName)';
} }
@@ -547,7 +549,7 @@ abstract mixin class _$BillProviderCopyWith<$Res> implements $BillProviderCopyWi
factory _$BillProviderCopyWith(_BillProvider value, $Res Function(_BillProvider) _then) = __$BillProviderCopyWithImpl; factory _$BillProviderCopyWith(_BillProvider value, $Res Function(_BillProvider) _then) = __$BillProviderCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
String clientId, String name, String description, bool requiresAccount, bool requiresAmount, bool? requiresAmountFromMerchant, bool requiresPhone, bool? requiresReversal, String? additionalDataString, String? processorType, String uid, String image, String label, String category, String? accountFieldName String id, String clientId, String name, String description, bool requiresAccount, bool requiresAmount, bool? requiresAmountFromMerchant, bool requiresPhone, bool? requiresReversal, String? additionalDataString, String? processorType, String uid, String image, String label, String category, String? accountFieldName
}); });
@@ -564,9 +566,10 @@ class __$BillProviderCopyWithImpl<$Res>
/// Create a copy of BillProvider /// Create a copy of BillProvider
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? clientId = null,Object? name = null,Object? description = null,Object? requiresAccount = null,Object? requiresAmount = null,Object? requiresAmountFromMerchant = freezed,Object? requiresPhone = null,Object? requiresReversal = freezed,Object? additionalDataString = freezed,Object? processorType = freezed,Object? uid = null,Object? image = null,Object? label = null,Object? category = null,Object? accountFieldName = freezed,}) { @override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? clientId = null,Object? name = null,Object? description = null,Object? requiresAccount = null,Object? requiresAmount = null,Object? requiresAmountFromMerchant = freezed,Object? requiresPhone = null,Object? requiresReversal = freezed,Object? additionalDataString = freezed,Object? processorType = freezed,Object? uid = null,Object? image = null,Object? label = null,Object? category = null,Object? accountFieldName = freezed,}) {
return _then(_BillProvider( return _then(_BillProvider(
clientId: null == clientId ? _self.clientId : clientId // ignore: cast_nullable_to_non_nullable id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,clientId: null == clientId ? _self.clientId : clientId // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String,requiresAccount: null == requiresAccount ? _self.requiresAccount : requiresAccount // ignore: cast_nullable_to_non_nullable as String,requiresAccount: null == requiresAccount ? _self.requiresAccount : requiresAccount // ignore: cast_nullable_to_non_nullable

View File

@@ -22,6 +22,7 @@ Map<String, dynamic> _$CategoryToJson(_Category instance) => <String, dynamic>{
_BillProvider _$BillProviderFromJson(Map<String, dynamic> json) => _BillProvider _$BillProviderFromJson(Map<String, dynamic> json) =>
_BillProvider( _BillProvider(
id: json['id'] as String,
clientId: json['clientId'] as String, clientId: json['clientId'] as String,
name: json['name'] as String, name: json['name'] as String,
description: json['description'] as String, description: json['description'] as String,
@@ -41,6 +42,7 @@ _BillProvider _$BillProviderFromJson(Map<String, dynamic> json) =>
Map<String, dynamic> _$BillProviderToJson(_BillProvider instance) => Map<String, dynamic> _$BillProviderToJson(_BillProvider instance) =>
<String, dynamic>{ <String, dynamic>{
'id': instance.id,
'clientId': instance.clientId, 'clientId': instance.clientId,
'name': instance.name, 'name': instance.name,
'description': instance.description, 'description': instance.description,

View File

@@ -1,11 +1,14 @@
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';
import 'package:qpay/auth_state.dart';
import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/accounts/widgets/account_balance_widget.dart';
import 'package:qpay/screens/home/home_controller.dart'; 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';
@@ -25,6 +28,7 @@ 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 = '';
late AnimationController _providersAnimController; late AnimationController _providersAnimController;
@@ -103,7 +107,7 @@ 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(); await homeController.getProviders(_isZWGSelected ? "ZWG" : "USD");
prefs = await SharedPreferences.getInstance(); prefs = await SharedPreferences.getInstance();
@@ -124,7 +128,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
Future<void> _onRefresh() async { Future<void> _onRefresh() async {
await homeController.getAccounts(); await homeController.getAccounts();
await homeController.getCategories(); await homeController.getCategories();
await homeController.getProviders(); await homeController.getProviders(_isZWGSelected ? "ZWG" : "USD");
prefs = await SharedPreferences.getInstance(); prefs = await SharedPreferences.getInstance();
if (prefs.getString("userId") != null) { if (prefs.getString("userId") != null) {
@@ -193,6 +197,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isMobile = constraints.maxWidth < ResponsivePolicy.md;
return Scaffold( return Scaffold(
backgroundColor: isDark backgroundColor: isDark
? const Color(0xFF0D0D0D) ? const Color(0xFF0D0D0D)
@@ -205,69 +211,21 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
child: CustomScrollView( child: CustomScrollView(
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
slivers: [ slivers: [
SliverAppBar( if (isMobile)
pinned: false, SliverAppBar(
floating: true, pinned: false,
stretch: true, floating: true,
surfaceTintColor: Colors.transparent, stretch: true,
backgroundColor: isDark surfaceTintColor: Colors.transparent,
? const Color(0xFF0D0D0D) backgroundColor: isDark
: const Color(0xFFF8F9FA), ? const Color(0xFF0D0D0D)
title: constraints.maxWidth < ResponsivePolicy.md : const Color(0xFFF8F9FA),
? Image( title: Image(
image: const AssetImage('assets/velocity.png'), image: const AssetImage('assets/velocity.png'),
width: 130, width: 130,
) ),
: null, actions: _buildAppBarActions(theme, isDark),
actions: [ ),
if (_isLoggedIn) ...[
Container(
width: 30,
height: 30,
margin: const EdgeInsets.only(right: 4),
decoration: BoxDecoration(
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.12)
: Colors.black87.withAlpha(20),
),
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
theme.colorScheme.primary.withValues(
alpha: 0.15,
),
theme.colorScheme.tertiary.withValues(
alpha: 0.05,
),
],
stops: const [0.3, 0.7],
begin: Alignment.topRight,
end: Alignment.bottomLeft,
),
),
child: Center(
child: Text(
initials,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : Colors.black87,
),
),
),
),
IconButton(
icon: Icon(
Icons.logout_rounded,
color: isDark ? Colors.white54 : Colors.black54,
size: 20,
),
onPressed: _showLogoutDialog,
),
],
],
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Center( child: Center(
child: SizedBox( child: SizedBox(
@@ -281,6 +239,10 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (!isMobile) ...[
_buildWebHeader(theme, isDark),
const SizedBox(height: 16),
],
FadeTransition( FadeTransition(
opacity: _providersFadeAnimation, opacity: _providersFadeAnimation,
child: SlideTransition( child: SlideTransition(
@@ -322,6 +284,71 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
); );
} }
// ──────────────────────────────────────────────────────────────
// App Bar Actions (shared between mobile SliverAppBar and web header)
// ──────────────────────────────────────────────────────────────
List<Widget> _buildAppBarActions(ThemeData theme, bool isDark) {
return [
if (_isLoggedIn) ...[
Container(
width: 30,
height: 30,
margin: const EdgeInsets.only(right: 4),
decoration: BoxDecoration(
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.12)
: Colors.black87.withAlpha(20),
),
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
theme.colorScheme.primary.withValues(alpha: 0.15),
theme.colorScheme.tertiary.withValues(alpha: 0.05),
],
stops: const [0.3, 0.7],
begin: Alignment.topRight,
end: Alignment.bottomLeft,
),
),
child: Center(
child: Text(
initials,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : Colors.black87,
),
),
),
),
IconButton(
icon: Icon(
Icons.logout_rounded,
color: isDark ? Colors.white54 : Colors.black54,
size: 20,
),
onPressed: _showLogoutDialog,
),
],
];
}
// ──────────────────────────────────────────────────────────────
// Web Header (replaces SliverAppBar on web/tablet)
// ──────────────────────────────────────────────────────────────
Widget _buildWebHeader(ThemeData theme, bool isDark) {
return Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: _buildAppBarActions(theme, isDark),
),
],
);
}
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
// Providers Section // Providers Section
// ────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────
@@ -338,19 +365,129 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (!_isLoggedIn) _buildLoginPrompt(theme, isDark), if (!_isLoggedIn) _buildLoginPrompt(theme, isDark),
if (_isLoggedIn) const AccountBalanceWidget(),
const SizedBox(height: 16), const SizedBox(height: 16),
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"),
IconButton( Row(
icon: Icon( mainAxisSize: MainAxisSize.min,
Icons.refresh_rounded, children: [
color: isDark ? Colors.white54 : Colors.black54, Container(
size: 20, padding: const EdgeInsets.symmetric(
), horizontal: 8,
onPressed: _onRefresh, vertical: 2,
tooltip: 'Refresh', ),
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(
icon: Icon(
Icons.refresh_rounded,
color: isDark ? Colors.white54 : Colors.black54,
size: 20,
),
onPressed: _onRefresh,
tooltip: 'Refresh',
),
],
), ),
], ],
), ),
@@ -635,28 +772,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Register or Login for more', 'Register or Login for more features',
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white : Colors.black87, color: isDark ? Colors.white : Colors.black87,
), ),
), ),
const SizedBox(height: 2),
InkWell(
onTap: () => _showFeaturesPopup(context),
borderRadius: BorderRadius.circular(4),
child: Text(
'features',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w500,
decoration: TextDecoration.underline,
decorationColor: theme.colorScheme.primary,
color: theme.colorScheme.primary,
),
),
),
], ],
), ),
), ),
@@ -711,6 +832,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
setState(() { setState(() {
_isLoggedIn = false; _isLoggedIn = false;
}); });
authState.refresh();
setupDataAndTransitions(); setupDataAndTransitions();
}, },
), ),
@@ -719,201 +841,4 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
}, },
); );
} }
// ──────────────────────────────────────────────────────────────
// Features Popup
// ──────────────────────────────────────────────────────────────
void _showFeaturesPopup(BuildContext context) {
showDialog(
context: context,
builder: (BuildContext context) {
return Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
elevation: 0,
backgroundColor: Colors.transparent,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
shape: BoxShape.rectangle,
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle,
),
child: const Image(
image: AssetImage("assets/favicon.png"),
width: 45,
),
),
const SizedBox(height: 16),
Text(
'Unlock Additional Features',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.onSurface,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'Register or login to access these great features',
style: TextStyle(
fontSize: 14,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.7),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
_buildFeatureItem(
Icons.account_balance_wallet,
'Dual Currency Wallets',
'USD and ZWG wallets for prepayments that can be utilized later',
),
const SizedBox(height: 16),
_buildFeatureItem(
Icons.history,
'Transaction History',
'All transactions are saved for reference from any device',
),
const SizedBox(height: 16),
_buildFeatureItem(
Icons.people,
'Recipient Management',
'Save and manage recipient details for future use',
),
const SizedBox(height: 16),
_buildFeatureItem(
Icons.schedule,
'Smart Payments',
'Schedule payments and split bills with friends',
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: TextButton(
onPressed: () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
'Maybe Later',
style: TextStyle(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
fontSize: 16,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
context.push('/onboarding/landing');
},
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
backgroundColor: Theme.of(
context,
).colorScheme.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text(
'Get Started',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
),
],
),
],
),
),
),
);
},
);
}
Widget _buildFeatureItem(IconData icon, String title, String description) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
icon,
size: 20,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
const SizedBox(height: 8),
Text(
description,
style: TextStyle(
fontSize: 14,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.7),
height: 1.4,
),
),
],
),
),
],
);
}
} }

View File

@@ -1,7 +1,6 @@
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:qpay/models/responsive_policy.dart'; import 'package:qpay/screens/onboarding/widgets/onboarding_layout.dart';
import 'package:qpay/screens/onboarding/login/login_controller.dart';
class CompleteScreen extends StatefulWidget { class CompleteScreen extends StatefulWidget {
const CompleteScreen({super.key}); const CompleteScreen({super.key});
@@ -13,65 +12,77 @@ class CompleteScreen extends StatefulWidget {
class _CompleteScreenState extends State<CompleteScreen> { class _CompleteScreenState extends State<CompleteScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( final colorScheme = Theme.of(context).colorScheme;
body: SingleChildScrollView( return OnboardingLayout(
child: Padding( // No back button - the user shouldn't be able to go back to the
padding: const EdgeInsets.all(25), // verification step after their account is fully set up.
child: Center( showBackButton: false,
child: LayoutBuilder( child: OnboardingCard(
builder: (context, constraints) { child: Column(
final maxWidth = constraints.maxWidth > ResponsivePolicy.md crossAxisAlignment: CrossAxisAlignment.stretch,
? ResponsivePolicy.md.toDouble() children: [
: constraints.maxWidth; // Success badge - green to differentiate from the brand primary.
return SizedBox( Container(
width: maxWidth, padding: const EdgeInsets.all(20),
child: Column( decoration: BoxDecoration(
mainAxisAlignment: MainAxisAlignment.center, color: Colors.green.withValues(alpha: 0.12),
crossAxisAlignment: CrossAxisAlignment.center, shape: BoxShape.circle,
children: [ ),
SizedBox(height: 75), child: const Icon(
Text( Icons.check_circle_rounded,
'Registration Complete', color: Colors.green,
style: TextStyle( size: 56,
fontSize: 30, ),
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
SizedBox(height: 5),
Text(
'Thank you for registering with Velocity payments',
style: TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
Image.asset('assets/landing.png', width: 300),
Text(
'Tap Login to get started',
style: TextStyle(fontSize: 18),
),
SizedBox(height: 30),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 25),
child: Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () {
context.go('/onboarding/login');
},
child: Text('Go to Login', style: TextStyle(fontSize: 16)),
),
),
],
),
),
SizedBox(height: 5),
],
),
);
}
), ),
), const SizedBox(height: 20),
Text(
"You're all set!",
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
color: colorScheme.onSurface,
letterSpacing: -0.5,
),
),
const SizedBox(height: 8),
Text(
'Welcome to Velocity. Your account is ready — sign in to start sending and receiving payments.',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.65),
height: 1.45,
),
),
const SizedBox(height: 28),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: colorScheme.primary.withValues(alpha: 0.2),
blurRadius: 24,
offset: const Offset(0, 12),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(16),
child: Image.asset(
'assets/complete.png',
width: 180,
fit: BoxFit.contain,
),
),
),
const SizedBox(height: 32),
OnboardingPrimaryButton(
label: 'Continue to sign in',
icon: Icons.arrow_forward_rounded,
onPressed: () {
context.go('/onboarding/login');
},
),
],
), ),
), ),
); );

View File

@@ -1,7 +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:provider/provider.dart';
import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/onboarding/login/login_controller.dart'; import 'package:qpay/screens/onboarding/onboarding_controller.dart';
class LandingScreen extends StatefulWidget { class LandingScreen extends StatefulWidget {
const LandingScreen({super.key}); const LandingScreen({super.key});
@@ -11,90 +12,647 @@ class LandingScreen extends StatefulWidget {
} }
class _LandingScreenState extends State<LandingScreen> { class _LandingScreenState extends State<LandingScreen> {
late OnboardingController onboardingController;
@override
void initState() {
super.initState();
onboardingController = Provider.of<OnboardingController>(
context,
listen: false,
);
}
void _goToRegister() {
// Reset the registration state so a fresh sign-up starts cleanly.
onboardingController.resetState();
context.go('/onboarding/phone');
}
// Max content width on large screens (desktop / web).
static const double _maxContentWidth = 1200;
// Max content width on tablet — the current mobile cap lifted slightly
// so the page breathes a bit on landscape phones / small tablets.
static const double _maxTabletWidth = 760;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final width = MediaQuery.of(context).size.width;
final isTablet = width >= ResponsivePolicy.md;
final isDesktop = width >= ResponsivePolicy.lg;
return Scaffold( return Scaffold(
body: SingleChildScrollView( // Use a Stack so the gradient background is guaranteed to cover the
child: Padding( // full viewport (including the area under the SafeArea) regardless
padding: const EdgeInsets.all(25), // of how tall the content ends up being.
child: Center( body: Stack(
child: LayoutBuilder( children: [
builder: (context, constraints) { Positioned.fill(
final maxWidth = constraints.maxWidth > ResponsivePolicy.md child: DecoratedBox(
? ResponsivePolicy.md.toDouble() decoration: BoxDecoration(
: constraints.maxWidth; gradient: LinearGradient(
return SizedBox( begin: Alignment.topLeft,
width: maxWidth, end: Alignment.bottomRight,
child: Column( colors: [
mainAxisAlignment: MainAxisAlignment.center, colorScheme.primary.withValues(alpha: 0.08),
crossAxisAlignment: CrossAxisAlignment.center, colorScheme.surface,
children: [ colorScheme.secondary.withValues(alpha: 0.05),
SizedBox(height: 75), ],
Text( ),
'Welcome to Velocity', ),
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
SizedBox(height: 5),
Text(
'The fastest way to pay for local goods and services',
style: TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
Image.asset('assets/landing.png', width: 300),
Image.asset('assets/velocity.png', width: 150),
SizedBox(height: 5),
Text(
'Register or login to get started',
style: TextStyle(fontSize: 16),
),
SizedBox(height: 50),
Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 25),
child: Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () {
context.go('/onboarding/register');
},
child: Text('Register', style: TextStyle(fontSize: 16)),
),
),
SizedBox(width: 16),
Expanded(
child: OutlinedButton(
onPressed: () {
context.go('/onboarding/login');
},
child: Text('Login', style: TextStyle(fontSize: 16)),
),
),
],
),
),
SizedBox(height: 10),
TextButton(
onPressed: () {
context.go('/');
},
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
),
],
)
],
),
);
},
), ),
), ),
SafeArea(
child: SingleChildScrollView(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _maxContentWidth),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: isDesktop ? 48 : 20,
vertical: isDesktop ? 32 : 16,
),
child: LayoutBuilder(
builder: (context, constraints) {
final contentMaxWidth = isDesktop
? _maxContentWidth
: (isTablet
? _maxTabletWidth
: constraints.maxWidth);
return SizedBox(
width: contentMaxWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 12),
_buildTopBar(theme, colorScheme),
SizedBox(height: isDesktop ? 40 : 24),
_buildHero(theme, colorScheme, isDesktop),
SizedBox(height: isDesktop ? 72 : 36),
_buildValuePropsHeadline(theme, colorScheme),
const SizedBox(height: 28),
_buildValueProps(isTablet),
SizedBox(height: isDesktop ? 64 : 32),
_buildCallToActions(
theme,
colorScheme,
isDesktop,
),
const SizedBox(height: 8),
Center(
child: TextButton(
onPressed: () {
context.go('/');
},
child: Text(
'Continue as guest?',
style: TextStyle(
fontSize: 14,
color: colorScheme.onSurface.withValues(
alpha: 0.7,
),
fontWeight: FontWeight.w500,
),
),
),
),
const SizedBox(height: 24),
_buildFooter(theme, colorScheme),
const SizedBox(height: 12),
],
),
);
},
),
),
),
),
),
),
],
),
);
}
Widget _buildTopBar(ThemeData theme, ColorScheme colorScheme) {
return Row(
children: [
Image(image: AssetImage('assets/velocity.png'), width: 150),
const Spacer(),
TextButton(
onPressed: () {
context.go('/onboarding/login');
},
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
textStyle: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
child: const Text('Sign in'),
),
],
);
}
Widget _buildHero(ThemeData theme, ColorScheme colorScheme, bool isDesktop) {
final textBlock = Column(
crossAxisAlignment: isDesktop
? CrossAxisAlignment.start
: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isDesktop
? Colors.white.withValues(alpha: 0.18)
: colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
border: isDesktop
? Border.all(color: Colors.white.withValues(alpha: 0.25))
: null,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.flash_on_rounded,
size: 14,
color: isDesktop ? Colors.white : colorScheme.primary,
),
const SizedBox(width: 6),
Text(
'Fast. Secure. Local.',
style: theme.textTheme.labelMedium?.copyWith(
color: isDesktop ? Colors.white : colorScheme.primary,
fontWeight: FontWeight.w700,
letterSpacing: 0.4,
),
),
],
),
),
SizedBox(height: isDesktop ? 20 : 16),
Text(
'Welcome to Velocity',
textAlign: isDesktop ? TextAlign.start : TextAlign.center,
style:
(isDesktop
? theme.textTheme.displaySmall
: theme.textTheme.headlineMedium)
?.copyWith(
color: isDesktop ? null : colorScheme.primary,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
height: 1.1,
),
),
SizedBox(height: isDesktop ? 16 : 8),
Text(
'The fastest way to pay for local goods and services.',
textAlign: isDesktop ? TextAlign.start : TextAlign.center,
style: theme.textTheme.titleMedium?.copyWith(
color: isDesktop
? colorScheme.onSurface.withValues(alpha: 0.75)
: colorScheme.onSurface.withValues(alpha: 0.8),
height: 1.4,
fontWeight: FontWeight.w400,
),
),
if (isDesktop) ...[
const SizedBox(height: 28),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
_buildHeroCta(context, colorScheme),
_buildHeroSecondaryCta(context, colorScheme),
],
),
],
],
);
final imageBlock = ClipRRect(
borderRadius: BorderRadius.circular(24),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: colorScheme.primary.withValues(alpha: 0.25),
blurRadius: 32,
offset: const Offset(0, 18),
),
],
),
child: Image.asset(
'assets/signup.png',
width: isDesktop ? 360 : 260,
fit: BoxFit.contain,
),
),
);
if (isDesktop) {
return Container(
padding: const EdgeInsets.all(40),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(32),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
colorScheme.primary.withValues(alpha: 0.06),
colorScheme.secondary.withValues(alpha: 0.04),
],
),
border: Border.all(
color: colorScheme.onSurface.withValues(alpha: 0.06),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(flex: 5, child: textBlock),
const SizedBox(width: 32),
Expanded(flex: 4, child: Center(child: imageBlock)),
],
),
);
}
// Mobile / tablet — original vertical hero with gradient background.
return Container(
padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 20),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(28),
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [colorScheme.primary, colorScheme.secondary],
),
boxShadow: [
BoxShadow(
color: colorScheme.primary.withValues(alpha: 0.25),
blurRadius: 24,
offset: const Offset(0, 12),
),
],
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: const Icon(
Icons.bolt_rounded,
color: Colors.white,
size: 44,
),
),
const SizedBox(height: 16),
Text(
'Welcome to Velocity',
textAlign: TextAlign.center,
style: theme.textTheme.headlineMedium?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
),
),
const SizedBox(height: 8),
Text(
'The fastest way to pay for local goods\nand services',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(
color: Colors.white.withValues(alpha: 0.92),
height: 1.4,
),
),
const SizedBox(height: 20),
imageBlock,
],
),
);
}
Widget _buildHeroCta(BuildContext context, ColorScheme colorScheme) {
return SizedBox(
height: 52,
child: ElevatedButton(
onPressed: _goToRegister,
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: Colors.white,
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 28),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Text('Create Free Account'),
SizedBox(width: 8),
Icon(Icons.arrow_forward_rounded, size: 20),
],
),
),
);
}
Widget _buildHeroSecondaryCta(BuildContext context, ColorScheme colorScheme) {
return SizedBox(
height: 52,
child: OutlinedButton(
onPressed: () {
context.go('/onboarding/login');
},
style: OutlinedButton.styleFrom(
side: BorderSide(color: colorScheme.primary, width: 1.5),
foregroundColor: colorScheme.primary,
padding: const EdgeInsets.symmetric(horizontal: 28),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
),
),
child: const Text('Sign in'),
),
);
}
Widget _buildValuePropsHeadline(ThemeData theme, ColorScheme colorScheme) {
return Column(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'Why register?',
style: theme.textTheme.labelLarge?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
),
),
),
const SizedBox(height: 14),
Text(
'Unlock the full Velocity experience',
textAlign: TextAlign.center,
style: theme.textTheme.headlineSmall?.copyWith(
color: colorScheme.onSurface,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 600),
child: Text(
'Create a free account to access powerful features designed to make your payments effortless.',
textAlign: TextAlign.center,
style: theme.textTheme.bodyLarge?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.65),
height: 1.45,
),
),
),
],
);
}
Widget _buildValueProps(bool isWide) {
final props = const [
_ValueProp(
icon: Icons.groups_2_rounded,
title: 'Batch Pay Groups',
description:
'Send payments to many recipients at once. Perfect for administrative bills, paying teams, or settling group obligations in a single tap.',
accent: Color(0xFF9D6711),
),
_ValueProp(
icon: Icons.devices_rounded,
title: 'Access Anywhere',
description:
'Your recipients and full transaction history sync seamlessly across all your devices — pick up right where you left off.',
accent: Color(0xFF8B0000),
),
_ValueProp(
icon: Icons.account_balance_wallet_rounded,
title: 'Prefund Your Wallet',
description:
'Top up your wallet today and use the balance whenever you need it. No rushing, no last-minute transfers.',
accent: Color(0xFFB7791F),
),
];
if (isWide) {
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: props
.map(
(p) => Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: p,
),
),
)
.toList(),
),
);
}
return Column(
children: props
.map(
(p) =>
Padding(padding: const EdgeInsets.only(bottom: 12), child: p),
)
.toList(),
);
}
Widget _buildCallToActions(
ThemeData theme,
ColorScheme colorScheme,
bool isDesktop,
) {
final registerButton = SizedBox(
width: double.infinity,
height: 54,
child: ElevatedButton(
onPressed: _goToRegister,
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
),
),
child: const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Create Free Account'),
SizedBox(width: 8),
Icon(Icons.arrow_forward_rounded, size: 20),
],
),
),
);
final loginButton = SizedBox(
width: double.infinity,
height: 54,
child: OutlinedButton(
onPressed: () {
context.go('/onboarding/login');
},
style: OutlinedButton.styleFrom(
side: BorderSide(color: colorScheme.primary, width: 1.5),
foregroundColor: colorScheme.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
),
),
child: const Text('I already have an account'),
),
);
if (isDesktop) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: Column(
children: [registerButton, const SizedBox(height: 12), loginButton],
),
),
);
}
return Column(
children: [registerButton, const SizedBox(height: 12), loginButton],
);
}
Widget _buildFooter(ThemeData theme, ColorScheme colorScheme) {
return Center(
child: Text(
'© Velocity · Fast, secure local payments',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.45),
letterSpacing: 0.2,
), ),
), ),
); );
} }
} }
class _ValueProp extends StatelessWidget {
const _ValueProp({
required this.icon,
required this.title,
required this.description,
required this.accent,
});
final IconData icon;
final String title;
final String description;
final Color accent;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: colorScheme.onSurface.withValues(alpha: 0.06),
width: 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 16,
offset: const Offset(0, 4),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(14),
),
child: Icon(icon, color: accent, size: 26),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 6),
Text(
description,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.68),
height: 1.5,
),
),
],
),
),
],
),
);
}
}

View File

@@ -1,5 +1,6 @@
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/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:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
@@ -60,15 +61,22 @@ class LoginController extends ChangeNotifier {
prefs.setString('username', username); prefs.setString('username', username);
prefs.setString('email', response['email']); prefs.setString('email', response['email']);
prefs.setString('firstName', response['firstName']); prefs.setString('firstName', response['firstName']);
prefs.setString('lastName', response['lastName']); prefs.setString('lastName', response['lastName'] ?? '');
prefs.setString('phone', response['phone']); prefs.setString('phone', response['phone']);
prefs.setString('userId', response['uuid']); prefs.setString('userId', response['uuid']);
prefs.setString( prefs.setString(
'initials', 'initials',
response['firstName'].substring(0, 1) + response['firstName'].substring(0, 1) +
response['lastName'].substring(0, 1), (response['lastName'] != null && response['lastName'].isNotEmpty
? response['lastName'].substring(0, 1)
: ''),
); );
// Notify the router that the user is now signed in so any
// auth-gated redirects (e.g. the groups flow) are re-evaluated
// and the user is sent to their originally requested page.
await authState.refresh();
return ApiResponse.success(model); return ApiResponse.success(model);
} else { } else {
model.errorMessage = model.errorMessage =

View File

@@ -1,15 +1,10 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/foundation.dart';
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:google_sign_in/google_sign_in.dart'; import 'package:provider/provider.dart';
import 'package:google_sign_in_web/web_only.dart' as web;
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/onboarding/login/login_controller.dart'; import 'package:qpay/screens/onboarding/login/login_controller.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:qpay/screens/onboarding/onboarding_controller.dart';
import 'package:qpay/screens/onboarding/widgets/onboarding_layout.dart';
import 'package:qpay/widgets/app_snack_bar.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
class LoginScreen extends StatefulWidget { class LoginScreen extends StatefulWidget {
@@ -20,242 +15,151 @@ class LoginScreen extends StatefulWidget {
} }
class _LoginScreenState extends State<LoginScreen> { class _LoginScreenState extends State<LoginScreen> {
late SharedPreferences prefs; late OnboardingController onboardingController;
late GoogleSignIn googleSignIn;
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final TextEditingController _emailController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
bool _obscurePassword = true;
bool _loading = false; bool _loading = false;
List<String> scopes = <String>[
'https://www.googleapis.com/auth/contacts.readonly',
];
late LoginController _loginController; late LoginController _loginController;
StreamSubscription<GoogleSignInAuthenticationEvent>? _authSubscription;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loginController = LoginController(context); _loginController = LoginController(context);
onboardingController = Provider.of<OnboardingController>(
context,
listen: false,
);
googleSignIn = GoogleSignIn.instance; // Pre-fill email if it was captured during registration.
if (kIsWeb) { final capturedEmail = onboardingController.model.email;
googleSignIn.initialize(); if (capturedEmail != null && capturedEmail.isNotEmpty) {
_authSubscription = googleSignIn.authenticationEvents _emailController.text = capturedEmail;
.handleError(_handleAuthenticationError)
.listen(_handleAuthenticationEvent);
} else {
unawaited(
googleSignIn.initialize(serverClientId: dotenv.env['CLIENTID']),
);
} }
} }
Future<void> signInWithGoogle() async {
if (GoogleSignIn.instance.supportsAuthenticate()) {
_authSubscription?.cancel();
unawaited(
GoogleSignIn.instance.authenticate().then((value) async {
_authSubscription = googleSignIn.authenticationEvents
.handleError(_handleAuthenticationError)
.listen(_handleAuthenticationEvent);
}),
);
}
}
void saveUser(GoogleSignInAccount user) async {
final Map<String, dynamic> data = <String, dynamic>{
'displayName': user.displayName,
'email': user.email,
'id': user.id,
'photoUrl': user.photoUrl,
};
prefs = await SharedPreferences.getInstance();
if (prefs.getString("googleUser") == null) {
prefs.setString("googleUser", jsonEncode(data));
}
}
Future<void> _handleAuthenticationEvent(
GoogleSignInAuthenticationEvent event,
) async {
if (!mounted) return;
// #docregion CheckAuthorization
final GoogleSignInAccount? user = // ...
// #enddocregion CheckAuthorization
switch (event) {
GoogleSignInAuthenticationEventSignIn() => event.user,
GoogleSignInAuthenticationEventSignOut() => null,
};
if (user == null || !mounted) return;
// Check for existing authorization.
// #docregion CheckAuthorization
final GoogleSignInClientAuthorization? authorization = await user
?.authorizationClient
.authorizationForScopes(scopes);
// #enddocregion CheckAuthorization
print(user);
saveUser(user);
if (!mounted) return;
final response = await _loginController.login(user.email, user.id);
if (response.isSuccess) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text("Login successful!"),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.green,
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(response.error ?? "Login failed"),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.deepOrange,
),
);
}
if (!mounted) return;
context.go('/');
}
Future<void> _handleAuthenticationError(Object e) async {
print(e.toString());
}
@override @override
void dispose() { void dispose() {
_authSubscription?.cancel(); _emailController.dispose();
_passwordController.dispose();
_loginController.dispose(); _loginController.dispose();
super.dispose(); super.dispose();
} }
Future<void> _onLogin() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
final email = _emailController.text.trim();
final password = _passwordController.text;
// Cache credentials in the onboarding model for use by the rest
// of the registration/login flow.
onboardingController.updateEmail(email);
onboardingController.updatePassword(password);
setState(() => _loading = true);
final response = await _loginController.login(email, password);
if (!mounted) return;
setState(() => _loading = false);
if (response.isSuccess) {
AppSnackBar.showSuccess(context, 'Login successful!');
} else {
AppSnackBar.showError(context, response.error ?? 'Login failed');
}
if (!mounted) return;
if (response.isSuccess) {
context.go('/');
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return OnboardingLayout(
body: SingleChildScrollView( showBackButton: true,
child: Padding( trailingLabel: 'Sign up',
padding: const EdgeInsets.all(25), trailingRoute: '/onboarding/phone',
child: Form( trailingIcon: Icons.person_add_alt_1_rounded,
key: _formKey, bottomLinkLabel: 'Continue as guest?',
child: Center( bottomLinkRoute: '/',
child: LayoutBuilder( child: OnboardingCard(
builder: (context, constraints) { child: Form(
final maxWidth = constraints.maxWidth > ResponsivePolicy.md key: _formKey,
? ResponsivePolicy.md.toDouble() child: Column(
: constraints.maxWidth; crossAxisAlignment: CrossAxisAlignment.stretch,
return SizedBox( children: [
width: maxWidth, const OnboardingHero(
child: Column( icon: Icons.lock_open_rounded,
mainAxisAlignment: MainAxisAlignment.center, title: 'Welcome back',
crossAxisAlignment: CrossAxisAlignment.center, subtitle: 'Sign in to continue with Velocity',
children: [ ),
SizedBox(height: 75), const SizedBox(height: 28),
Text( OnboardingTextField(
'Login', controller: _emailController,
style: TextStyle( hintText: 'Email address',
fontSize: 30, prefixIcon: Icons.email_outlined,
fontWeight: FontWeight.bold, keyboardType: TextInputType.emailAddress,
color: Theme.of(context).colorScheme.primary, validator: (value) {
), if (value == null || value.trim().isEmpty) {
), return 'Please enter your email address';
SizedBox(height: 5), }
Text( final emailRegex = RegExp(r'^[\w\.\-+]+@[\w\.\-]+\.\w+$');
'Welcome back to Velocity', if (!emailRegex.hasMatch(value.trim())) {
style: TextStyle(fontSize: 18), return 'Please enter a valid email address';
textAlign: TextAlign.center, }
), return null;
SizedBox(height: 30),
Image.asset('assets/password.png', width: 300),
Text(
'Tap on your preferred social media platform to get started',
style: TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
// Username Field
SizedBox(height: 20),
// Forgot Password Link
// Divider with "or" text
Row(
children: [
Expanded(
child: Divider(color: Colors.grey.shade300),
),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text(
'choose from the options below to continue',
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 14,
),
textAlign: TextAlign.center,
),
),
Expanded(
child: Divider(color: Colors.grey.shade300),
),
],
),
SizedBox(height: 30),
// Social Login Buttons
if (GoogleSignIn.instance.supportsAuthenticate())
_buildGoogleButton()
else ...<Widget>[if (kIsWeb) web.renderButton()],
SizedBox(height: 40),
TextButton(
onPressed: () {
context.go('/');
},
child: Text(
'Continue as guest?',
style: TextStyle(fontSize: 14),
),
),
],
),
);
}, },
), ),
), const SizedBox(height: 14),
OnboardingTextField(
controller: _passwordController,
hintText: 'Password',
prefixIcon: Icons.lock_outline,
obscureText: _obscurePassword,
enableSuggestions: false,
autocorrect: false,
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
),
tooltip: _obscurePassword ? 'Show password' : 'Hide password',
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your password';
}
return null;
},
),
const SizedBox(height: 28),
Skeletonizer(
enabled: _loading,
child: OnboardingPrimaryButton(
label: 'Sign in',
icon: Icons.arrow_forward_rounded,
onPressed: _onLogin,
),
),
],
), ),
), ),
), ),
); );
} }
Widget _buildGoogleButton() {
return Skeletonizer(
enabled: _loading,
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
signInWithGoogle();
},
icon: Image.asset('assets/google.png', width: 20, height: 20),
label: Text('Google', style: TextStyle(fontSize: 16)),
style: OutlinedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 16),
side: BorderSide(color: Colors.grey.shade300),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
SizedBox(width: 16),
],
),
);
}
} }

View File

@@ -1,37 +1,50 @@
import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:qpay/http/http.dart';
class OnboardingModel { class RegistrationModel {
String? email;
String? firstName;
String? lastName;
String? phone; String? phone;
String? password;
String? workflowId; String? workflowId;
String? username;
GoogleSignInAccount? googleUser;
bool isLoading = false; bool isLoading = false;
} }
class OnboardingController extends ChangeNotifier { class OnboardingController extends ChangeNotifier {
OnboardingModel model = OnboardingModel(); RegistrationModel model = RegistrationModel();
void resetState() { void resetState() {
model = OnboardingModel(); model = RegistrationModel();
} }
void updateModel(Map mapModel) { void updateModel(Map mapModel) {
model.workflowId = mapModel['workflowId']; model.workflowId = mapModel['workflowId'];
model.phone = mapModel['phone']; model.phone = mapModel['phone'];
model.username = mapModel['username'];
notifyListeners(); notifyListeners();
} }
updatePhone(String phone) { void updateEmail(String email) {
model.email = email;
notifyListeners();
}
void updateFirstName(String firstName) {
model.firstName = firstName;
notifyListeners();
}
void updateLastName(String lastName) {
model.lastName = lastName;
notifyListeners();
}
void updatePhone(String phone) {
model.phone = phone; model.phone = phone;
notifyListeners(); notifyListeners();
} }
updateGoogleUser(GoogleSignInAccount googleUser) { void updatePassword(String password) {
model.googleUser = googleUser; model.password = password;
notifyListeners(); notifyListeners();
} }
}
}

View File

@@ -1,255 +0,0 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class PasswordScreen extends StatefulWidget {
const PasswordScreen({super.key});
@override
State<PasswordScreen> createState() => _PasswordScreenState();
}
class _PasswordScreenState extends State<PasswordScreen> {
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _confirmPasswordController =
TextEditingController();
final FocusNode _passwordFocusNode = FocusNode();
final FocusNode _confirmPasswordFocusNode = FocusNode();
bool _obscurePassword = true;
bool _obscureConfirmPassword = true;
@override
void dispose() {
_passwordController.dispose();
_confirmPasswordController.dispose();
_passwordFocusNode.dispose();
_confirmPasswordFocusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(25),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: 75),
Text(
'Set New Password',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
SizedBox(height: 5),
Text(
'Create a strong password to secure your account',
style: TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
Image.asset('assets/password.png', width: 300),
// Password Input
TextField(
controller: _passwordController,
focusNode: _passwordFocusNode,
obscureText: _obscurePassword,
decoration: InputDecoration(
labelText: 'New Password',
hintText: 'Enter your new password',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
),
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility
: Icons.visibility_off,
color: Colors.grey.shade600,
),
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
),
),
SizedBox(height: 20),
// Confirm Password Input
TextField(
controller: _confirmPasswordController,
focusNode: _confirmPasswordFocusNode,
obscureText: _obscureConfirmPassword,
decoration: InputDecoration(
labelText: 'Confirm Password',
hintText: 'Confirm your new password',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
),
contentPadding: EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
suffixIcon: IconButton(
icon: Icon(
_obscureConfirmPassword
? Icons.visibility
: Icons.visibility_off,
color: Colors.grey.shade600,
),
onPressed: () {
setState(() {
_obscureConfirmPassword = !_obscureConfirmPassword;
});
},
),
),
),
SizedBox(height: 20),
// Password Requirements
Container(
padding: EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.grey.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Password Requirements:',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
color: Colors.grey.shade700,
),
),
SizedBox(height: 8),
_buildRequirement('At least 8 characters', true),
_buildRequirement('Contains uppercase letter', true),
_buildRequirement('Contains lowercase letter', true),
_buildRequirement('Contains number', true),
_buildRequirement('Contains special character', false),
],
),
),
SizedBox(height: 40),
],
),
),
),
bottomNavigationBar: SizedBox(
height: 130,
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 25),
child: Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () {
// TODO: Add password update logic
String password = _passwordController.text;
String confirmPassword =
_confirmPasswordController.text;
print('Password: $password');
print('Confirm Password: $confirmPassword');
context.go('/onboarding/login');
},
style: ElevatedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
'Update Password',
style: TextStyle(fontSize: 16),
),
),
),
SizedBox(width: 16),
Expanded(
child: OutlinedButton(
onPressed: () {
context.go('/onboarding/login');
},
style: OutlinedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 16),
side: BorderSide(color: Colors.grey.shade300),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: Text(
'Back to Login',
style: TextStyle(fontSize: 16),
),
),
),
],
),
),
SizedBox(height: 20),
TextButton(
onPressed: () {
context.go('/');
},
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
),
],
),
),
);
}
Widget _buildRequirement(String text, bool isMet) {
return Padding(
padding: EdgeInsets.symmetric(vertical: 2),
child: Row(
children: [
Icon(
isMet ? Icons.check_circle : Icons.circle_outlined,
size: 16,
color: isMet ? Colors.green : Colors.grey.shade600,
),
SizedBox(width: 8),
Text(
text,
style: TextStyle(
fontSize: 12,
color: isMet ? Colors.grey.shade600 : Colors.grey.shade400,
),
),
],
),
);
}
}

View File

@@ -35,30 +35,29 @@ class PhoneController extends AbstractController {
notifyListeners(); notifyListeners();
} }
Future<ApiResponse<Map<String, dynamic>>> register() async { Future<ApiResponse<Map<String, dynamic>>> register() async {
prefs = await SharedPreferences.getInstance(); prefs = await SharedPreferences.getInstance();
Map user = { Map user = {
"username": onboardingController.model.googleUser?.email, "username": onboardingController.model.email,
"email": onboardingController.model.googleUser?.email, "email": onboardingController.model.email,
"firstName": onboardingController.model.googleUser?.displayName?.split(" ").first, "firstName": onboardingController.model.firstName,
"lastName": onboardingController.model.googleUser?.displayName?.split(" ").last, "lastName": onboardingController.model.lastName,
"password": onboardingController.model.googleUser?.id, "password": onboardingController.model.password,
"photoUrl": onboardingController.model.googleUser?.photoUrl,
"phone": onboardingController.model.phone, "phone": onboardingController.model.phone,
"tempUid": prefs.get("userId") "tempUid": prefs.get("userId"),
}; };
try { try {
model.isLoading = true; model.isLoading = true;
notifyListeners();
Map response = await http.post( Map response = await http.post(
'/auth/register', '/auth/register',
user user,
); );
model.status = response['state']; model.status = response['state'];
if(response['state'] == 'failed') { if (response['state'] == 'failed') {
model.errorMessage = response['message']; model.errorMessage = response['message'];
showErrorSnackBar(model.errorMessage); showErrorSnackBar(model.errorMessage);
model.isLoading = false; model.isLoading = false;
@@ -79,8 +78,9 @@ class PhoneController extends AbstractController {
); );
model.isLoading = false; model.isLoading = false;
notifyListeners(); notifyListeners();
return ApiResponse.failure("Problem during registration, please try again or contact support?"); return ApiResponse.failure(
"Problem during registration, please try again or contact support?",
);
} }
} }
}
}

View File

@@ -1,8 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:qpay/models/responsive_policy.dart'; import 'package:provider/provider.dart';
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
import 'package:qpay/screens/onboarding/phone/phone_controller.dart'; import 'package:qpay/screens/onboarding/phone/phone_controller.dart';
import 'package:qpay/screens/onboarding/widgets/onboarding_layout.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
class PhoneScreen extends StatefulWidget { class PhoneScreen extends StatefulWidget {
@@ -14,10 +15,18 @@ class PhoneScreen extends StatefulWidget {
class _PhoneScreenState extends State<PhoneScreen> { class _PhoneScreenState extends State<PhoneScreen> {
late PhoneController phoneController; late PhoneController phoneController;
late OnboardingController onboardingController;
final _formKey = GlobalKey<FormState>(); final _formKey = GlobalKey<FormState>();
final TextEditingController _phoneController = TextEditingController(); final TextEditingController _phoneController = TextEditingController();
final TextEditingController _emailController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _confirmPasswordController =
TextEditingController();
final TextEditingController _fullNameController = TextEditingController();
String selectedCountryCode = '+263'; // Default to ZW String selectedCountryCode = '+263'; // Default to ZW
bool _obscurePassword = true;
bool _obscureConfirmPassword = true;
// Common country codes with flags and names // Common country codes with flags and names
final List<Map<String, String>> countryCodes = [ final List<Map<String, String>> countryCodes = [
@@ -48,27 +57,81 @@ class _PhoneScreenState extends State<PhoneScreen> {
void initState() { void initState() {
super.initState(); super.initState();
phoneController = PhoneController(context); phoneController = PhoneController(context);
onboardingController = Provider.of<OnboardingController>(
context,
listen: false,
);
// Pre-fill the email field if it has already been captured.
final existingEmail = onboardingController.model.email;
if (existingEmail != null && existingEmail.isNotEmpty) {
_emailController.text = existingEmail;
}
// Pre-fill the password field if it has already been captured.
final existingPassword = onboardingController.model.password;
if (existingPassword != null && existingPassword.isNotEmpty) {
_passwordController.text = existingPassword;
_confirmPasswordController.text = existingPassword;
}
// Pre-fill the full name from firstName + lastName if previously captured.
final firstName = onboardingController.model.firstName;
final lastName = onboardingController.model.lastName;
final combined = [
if (firstName != null && firstName.isNotEmpty) firstName,
if (lastName != null && lastName.isNotEmpty) lastName,
].join(' ');
if (combined.isNotEmpty) {
_fullNameController.text = combined;
}
} }
Future<void> updatePhoneNumber(String phoneNumber) async { @override
String existingPhone = phoneNumber; void dispose() {
if (existingPhone.isNotEmpty) { _phoneController.dispose();
// Try to extract country code from existing phone number _emailController.dispose();
for (var country in countryCodes) { _passwordController.dispose();
if (existingPhone.startsWith(country['code']!)) { _confirmPasswordController.dispose();
setState(() { _fullNameController.dispose();
selectedCountryCode = country['code']!; super.dispose();
}); }
_phoneController.text = existingPhone.substring(
country['code']!.length, /// Splits a full name into a [firstName] and [lastName] using a single
); /// space as the delimiter.
break; ///
} /// Examples:
} /// "John" -> firstName: "John", lastName: ""
// If no country code found, use default and set the full number /// "John Doe" -> firstName: "John", lastName: "Doe"
if (_phoneController.text.isEmpty) { /// "John Michael Doe" -> firstName: "John", lastName: "Michael Doe"
_phoneController.text = existingPhone; /// " John Doe " -> firstName: "John", lastName: "Doe"
} ({String firstName, String lastName}) splitFullName(String fullName) {
final trimmed = fullName.trim();
if (trimmed.isEmpty) {
return (firstName: '', lastName: '');
}
final parts = trimmed.split(RegExp(r'\s+'));
if (parts.length == 1) {
return (firstName: parts.first, lastName: '');
}
return (firstName: parts.first, lastName: parts.sublist(1).join(' '));
}
Future<void> _onSubmit() async {
if (!(_formKey.currentState?.validate() ?? false)) return;
final fullPhoneNumber = _phoneController.text;
final email = _emailController.text.trim();
final password = _passwordController.text;
final nameParts = splitFullName(_fullNameController.text);
phoneController.updatePhone(fullPhoneNumber);
onboardingController.updateEmail(email);
onboardingController.updatePassword(password);
onboardingController.updateFirstName(nameParts.firstName);
onboardingController.updateLastName(nameParts.lastName);
await phoneController.register();
if (phoneController.model.status != 'failed') {
if (!mounted) return;
context.go('/onboarding/verify');
} }
} }
@@ -77,109 +140,139 @@ class _PhoneScreenState extends State<PhoneScreen> {
return ListenableBuilder( return ListenableBuilder(
listenable: phoneController, listenable: phoneController,
builder: (context, child) { builder: (context, child) {
return Scaffold( return OnboardingLayout(
body: SingleChildScrollView( showBackButton: true,
child: Padding( trailingLabel: 'Sign in',
padding: const EdgeInsets.all(25.0), trailingRoute: '/onboarding/login',
child: Form( trailingIcon: Icons.login_rounded,
key: _formKey, bottomLinkLabel: 'Continue as guest?',
child: Center( bottomLinkRoute: '/',
child: LayoutBuilder( child: OnboardingCard(
builder: (context, constraints) { child: Form(
final maxWidth = key: _formKey,
constraints.maxWidth > ResponsivePolicy.md child: Column(
? ResponsivePolicy.md.toDouble() crossAxisAlignment: CrossAxisAlignment.stretch,
: constraints.maxWidth; children: [
return SizedBox( const OnboardingHero(
width: maxWidth, icon: Icons.person_add_alt_1_rounded,
child: Column( title: 'Create your account',
mainAxisAlignment: MainAxisAlignment.center, subtitle:
crossAxisAlignment: CrossAxisAlignment.center, 'It only takes a minute — we\'ll send a verification code to confirm your details.',
children: <Widget>[ ),
SizedBox(height: 75), const SizedBox(height: 28),
Text( OnboardingTextField(
'Verify Your Device', controller: _fullNameController,
style: TextStyle( hintText: 'Full name',
fontSize: 30, prefixIcon: Icons.person_outline,
fontWeight: FontWeight.bold, textCapitalization: TextCapitalization.words,
color: Theme.of(context).colorScheme.primary, validator: (value) {
), if (value == null || value.trim().isEmpty) {
textAlign: TextAlign.center, return 'Please enter your full name';
), }
SizedBox(height: 10), if (value.trim().length < 2) {
Text( return 'Name is too short';
'We will send you a verification code to this number', }
style: TextStyle(fontSize: 18), return null;
textAlign: TextAlign.center,
),
Image.asset('assets/phone.png', width: 300),
SizedBox(height: 20),
_buildPhoneField(),
SizedBox(height: 30),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 25,
),
child: Skeletonizer(
enabled: phoneController.model.isLoading,
child: Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () async {
if (_formKey.currentState!
.validate()) {
String fullPhoneNumber =
_phoneController.text;
phoneController.updatePhone(
fullPhoneNumber,
);
await phoneController.register();
if (phoneController.model.status !=
'failed') {
context.go('/onboarding/verify');
}
}
},
child: Text(
'Send Code',
style: TextStyle(fontSize: 16),
),
),
),
SizedBox(width: 16),
Expanded(
child: OutlinedButton(
onPressed: () {
context.go('/onboarding/login');
},
child: Text(
'Go to Login',
style: TextStyle(fontSize: 16),
),
),
),
],
),
),
),
SizedBox(height: 10),
TextButton(
onPressed: () {
context.go('/');
},
child: Text(
'Continue as guest?',
style: TextStyle(fontSize: 14),
),
),
],
),
);
}, },
), ),
), const SizedBox(height: 14),
_buildPhoneField(),
const SizedBox(height: 14),
OnboardingTextField(
controller: _emailController,
hintText: 'Email address',
prefixIcon: Icons.email_outlined,
keyboardType: TextInputType.emailAddress,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Please enter your email address';
}
final emailRegex = RegExp(r'^[\w\.\-+]+@[\w\.\-]+\.\w+$');
if (!emailRegex.hasMatch(value.trim())) {
return 'Please enter a valid email address';
}
return null;
},
),
const SizedBox(height: 14),
OnboardingTextField(
controller: _passwordController,
hintText: 'Create a password',
prefixIcon: Icons.lock_outline,
obscureText: _obscurePassword,
enableSuggestions: false,
autocorrect: false,
suffixIcon: IconButton(
icon: Icon(
_obscurePassword
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
),
tooltip: _obscurePassword
? 'Show password'
: 'Hide password',
onPressed: () {
setState(() {
_obscurePassword = !_obscurePassword;
});
},
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a password';
}
if (value.length < 8) {
return 'Password must be at least 8 characters';
}
if (value.length > 128) {
return 'Password is too long';
}
return null;
},
),
const SizedBox(height: 14),
OnboardingTextField(
controller: _confirmPasswordController,
hintText: 'Confirm your password',
prefixIcon: Icons.lock_outline,
obscureText: _obscureConfirmPassword,
enableSuggestions: false,
autocorrect: false,
suffixIcon: IconButton(
icon: Icon(
_obscureConfirmPassword
? Icons.visibility_rounded
: Icons.visibility_off_rounded,
),
tooltip: _obscureConfirmPassword
? 'Show password'
: 'Hide password',
onPressed: () {
setState(() {
_obscureConfirmPassword = !_obscureConfirmPassword;
});
},
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please confirm your password';
}
if (value != _passwordController.text) {
return 'Passwords do not match';
}
return null;
},
),
const SizedBox(height: 28),
Skeletonizer(
enabled: phoneController.model.isLoading,
child: OnboardingPrimaryButton(
label: 'Send verification code',
icon: Icons.arrow_forward_rounded,
onPressed: _onSubmit,
),
),
],
), ),
), ),
), ),
@@ -189,94 +282,82 @@ class _PhoneScreenState extends State<PhoneScreen> {
} }
Widget _buildPhoneField() { Widget _buildPhoneField() {
return Column( final colorScheme = Theme.of(context).colorScheme;
return Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( // Country code dropdown
mainAxisAlignment: MainAxisAlignment.start, SizedBox(
children: [ width: 110,
// Country code dropdown child: Container(
Container( height: 47,
width: 100, decoration: BoxDecoration(
height: 55, color: colorScheme.surface,
decoration: BoxDecoration( border: Border.all(
border: Border.all( color: colorScheme.onSurface.withValues(alpha: 0.12),
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
),
borderRadius: BorderRadius.circular(12),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: selectedCountryCode,
isExpanded: true,
icon: const Icon(Icons.arrow_drop_down),
padding: const EdgeInsets.symmetric(horizontal: 8),
items: countryCodes.map((country) {
return DropdownMenuItem<String>(
value: country['code'],
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(country['flag']!),
const SizedBox(width: 4),
Text(
country['code']!,
style: const TextStyle(fontSize: 16),
),
],
),
);
}).toList(),
onChanged: (String? newValue) {
if (newValue != null) {
setState(() {
selectedCountryCode = newValue;
});
}
},
),
), ),
borderRadius: BorderRadius.circular(14),
), ),
const SizedBox(width: 8), child: DropdownButtonHideUnderline(
// Phone number field child: DropdownButton<String>(
Expanded( value: selectedCountryCode,
child: TextFormField( isExpanded: true,
controller: _phoneController, icon: const Icon(Icons.arrow_drop_down_rounded),
keyboardType: TextInputType.numberWithOptions(decimal: true), padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: InputDecoration( borderRadius: BorderRadius.circular(14),
hintText: 'Enter phone number', items: countryCodes.map((country) {
focusedErrorBorder: OutlineInputBorder( return DropdownMenuItem<String>(
borderRadius: BorderRadius.circular(12), value: country['code'],
borderSide: BorderSide( child: Row(
color: Theme.of(context).colorScheme.secondary, mainAxisSize: MainAxisSize.min,
children: [
Text(country['flag']!),
const SizedBox(width: 6),
Text(
country['code']!,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
],
), ),
), );
enabledBorder: OutlineInputBorder( }).toList(),
borderRadius: BorderRadius.circular(12), onChanged: (String? newValue) {
borderSide: BorderSide( if (newValue != null) {
color: Theme.of( setState(() {
context, selectedCountryCode = newValue;
).colorScheme.primary.withOpacity(0.2), });
),
),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a phone number';
} }
// Remove any non-digit characters for validation
String digitsOnly = value.replaceAll(RegExp(r'[^\d]'), '');
if (digitsOnly.length < 7) {
return 'Phone number must be at least 7 digits';
}
if (digitsOnly.length > 15) {
return 'Phone number is too long';
}
return null;
}, },
), ),
), ),
], ),
),
const SizedBox(width: 10),
// Phone number field
Expanded(
child: OnboardingTextField(
controller: _phoneController,
hintText: 'Phone number',
prefixIcon: Icons.phone_outlined,
keyboardType: TextInputType.numberWithOptions(decimal: true),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a phone number';
}
// Remove any non-digit characters for validation
final digitsOnly = value.replaceAll(RegExp(r'[^\d]'), '');
if (digitsOnly.length < 7) {
return 'Phone number must be at least 7 digits';
}
if (digitsOnly.length > 15) {
return 'Phone number is too long';
}
return null;
},
),
), ),
], ],
); );

View File

@@ -1,276 +0,0 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:go_router/go_router.dart';
import 'package:google_sign_in/google_sign_in.dart';
import 'package:google_sign_in_web/web_only.dart' as web;
import 'package:provider/provider.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
import 'package:skeletonizer/skeletonizer.dart';
class RegisterScreen extends StatefulWidget {
const RegisterScreen({super.key});
@override
State<RegisterScreen> createState() => _RegisterScreenState();
}
class _RegisterScreenState extends State<RegisterScreen> {
late OnboardingController onboardingController;
late GoogleSignIn googleSignIn;
bool _loading = false;
List<String> scopes = <String>[
'https://www.googleapis.com/auth/contacts.readonly',
];
@override
void initState() {
super.initState();
onboardingController = Provider.of<OnboardingController>(
context,
listen: false,
);
// onboardingController.resetState();
googleSignIn = GoogleSignIn.instance;
if(kIsWeb){
googleSignIn.initialize();
googleSignIn.authenticationEvents
.listen(_handleAuthenticationEvent)
.onError(_handleAuthenticationError);
}else {
unawaited(googleSignIn.initialize(serverClientId: dotenv.env['CLIENTID']));
}
}
void signInWithGoogle() {
if (GoogleSignIn.instance.supportsAuthenticate()){
unawaited(GoogleSignIn.instance.authenticate().then((value) {
googleSignIn.authenticationEvents
.listen(_handleAuthenticationEvent)
.onError(_handleAuthenticationError);
}));
}
}
Future<void> _handleAuthenticationEvent(GoogleSignInAuthenticationEvent event) async {
// #docregion CheckAuthorization
final GoogleSignInAccount? user = // ...
// #enddocregion CheckAuthorization
switch (event) {
GoogleSignInAuthenticationEventSignIn() => event.user,
GoogleSignInAuthenticationEventSignOut() => null,
};
// Check for existing authorization.
// #docregion CheckAuthorization
final GoogleSignInClientAuthorization? authorization = await user
?.authorizationClient
.authorizationForScopes(scopes);
// #enddocregion CheckAuthorization
print(user);
onboardingController.updateGoogleUser(user as GoogleSignInAccount);
}
Future<void> _handleAuthenticationError(Object e) async {
print(e.toString());
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: onboardingController,
builder: (context, child) {
return Scaffold(
body: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(25),
child: Center(
child: LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
? ResponsivePolicy.md.toDouble()
: constraints.maxWidth;
return SizedBox(
width: maxWidth,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: 75),
Text(
'Registration',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
SizedBox(height: 5),
Text(
"Let's start by getting some of your online details to get you started",
style: TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
Image.asset('assets/social.png', width: 300),
SizedBox(height: 20),
// Social Login Buttons
if(onboardingController.model.googleUser == null)
Column(
children: [
Row(
children: [
Expanded(child: Divider(color: Colors.grey.shade300)),
Padding(
padding: EdgeInsets.symmetric(horizontal: 16),
child: Text(
'Choose from the options below to continue',
style: TextStyle(
color: Colors.grey.shade600,
fontSize: 14,
),
textAlign: TextAlign.center,
),
),
Expanded(child: Divider(color: Colors.grey.shade300)),
],
),
SizedBox(height: 20),
if(GoogleSignIn.instance.supportsAuthenticate())
_buildGoogleButton()
else ...<Widget>[
if (kIsWeb)
web.renderButton()
],
],
),
if(onboardingController.model.googleUser != null)
Column(
children: [
SizedBox(
width: 100,
child: Divider(
color: Colors.grey.shade300,
thickness: 1,
),
),
SizedBox(height: 20,),
Text(
"Welcome, ${onboardingController.model.googleUser?.displayName}",
style: TextStyle(fontSize: 25),
textAlign: TextAlign.center,
),
SizedBox(height: 10,),
Text("Tap Proceed to continue", style: TextStyle(fontSize: 18),)
],
),
SizedBox(height: 50),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 25),
child: Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () {
if (onboardingController.model.googleUser == null) {
showDialog(
context: context,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Sign-In Required'),
content: const Text(
"Please sign in with one of the social media platforms to proceed."),
actions: <Widget>[
TextButton(
child: const Text('OK'),
onPressed: () {
Navigator.of(dialogContext).pop(); // Close the dialog
},
),
],
);
},
);
} else {
context.go('/onboarding/phone');
}
},
child: Text('Proceed', style: TextStyle(fontSize: 16)),
),
),
SizedBox(width: 16),
Expanded(
child: OutlinedButton(
onPressed: () {
context.go('/onboarding/login');
},
child: Text(
'Go to Login',
style: TextStyle(fontSize: 16),
),
),
),
],
),
),
SizedBox(height: 10),
TextButton(
onPressed: () {
context.go('/');
},
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
),
],
),
);
}
),
),
),
),
);
}
);
}
Widget _buildGoogleButton(){
return Skeletonizer(
enabled: _loading,
child: Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () {
signInWithGoogle();
},
icon: Image.asset(
'assets/google.png',
width: 20,
height: 20,
),
label: Text('Google', style: TextStyle(fontSize: 16)),
style: OutlinedButton.styleFrom(
padding: EdgeInsets.symmetric(vertical: 16),
side: BorderSide(color: Colors.grey.shade300),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
SizedBox(width: 16),
],
),
);
}
}

View File

@@ -1,3 +1,6 @@
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:qpay/abstracts/AbstractController.dart'; import 'package:qpay/abstracts/AbstractController.dart';
@@ -19,7 +22,7 @@ class VerifyController extends AbstractController {
VerifyModel model = VerifyModel(); VerifyModel model = VerifyModel();
VerifyController(this.context){ VerifyController(this.context) {
onboardingController = Provider.of<OnboardingController>( onboardingController = Provider.of<OnboardingController>(
context, context,
listen: false, listen: false,
@@ -31,9 +34,64 @@ class VerifyController extends AbstractController {
notifyListeners(); notifyListeners();
} }
Future<ApiResponse<Map<String, dynamic>>> verifyOtp () async { /// Extracts a human-readable error message from any exception thrown during
/// an HTTP call. Supports:
/// * [DioException] the response body, request body, and a top-level
/// `message` field are inspected.
/// * Dart [Exception] / [Error] whose stringified form is a JSON object
/// (e.g. `{state: failed, status: manual, body: {...}, message: ...}`).
/// * Any other throwable falls back to its `toString()`.
String _extractErrorMessage(Object error) {
try {
if (error is DioException) {
final data = error.response?.data;
if (data is Map) {
final message = data['message'];
if (message is String && message.isNotEmpty) return message;
}
if (data is String && data.isNotEmpty) {
final parsed = _tryParseJson(data);
if (parsed is Map) {
final message = parsed['message'];
if (message is String && message.isNotEmpty) return message;
}
return data;
}
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.receiveTimeout ||
error.type == DioExceptionType.sendTimeout) {
return 'The request timed out. Please check your connection and try again.';
}
if (error.type == DioExceptionType.connectionError) {
return 'No internet connection. Please check your network and try again.';
}
return error.message ?? 'Network error. Please try again.';
}
final raw = error.toString();
// The server sometimes throws a stringified JSON object as an exception.
final parsed = _tryParseJson(raw);
if (parsed is Map) {
final message = parsed['message'];
if (message is String && message.isNotEmpty) return message;
}
return raw;
} catch (_) {
return 'An unexpected error occurred. Please try again.';
}
}
dynamic _tryParseJson(String input) {
try {
return jsonDecode(input);
} catch (_) {
return null;
}
}
Future<ApiResponse<Map<String, dynamic>>> verifyOtp() async {
Map user = { Map user = {
"username": onboardingController.model.googleUser?.email, "username": onboardingController.model.email,
"otpCode": model.code, "otpCode": model.code,
"otpType": "REGISTRATION", "otpType": "REGISTRATION",
"workflowId": onboardingController.model.workflowId, "workflowId": onboardingController.model.workflowId,
@@ -41,62 +99,61 @@ class VerifyController extends AbstractController {
try { try {
model.isLoading = true; model.isLoading = true;
model.errorMessage = null;
notifyListeners(); notifyListeners();
Map response = await http.post( Map response = await http.post('/auth/verify', user);
'/auth/verify',
user
);
model.status = response['state']; model.status = response['state'];
model.isLoading = false; model.isLoading = false;
notifyListeners(); notifyListeners();
if(response['state'] == 'failed') { if (response['state'] == 'failed') {
model.errorMessage = response['message']; final message =
return ApiResponse.failure(response['message'] ?? "Verification failed"); response['message']?.toString() ?? "Verification failed";
model.errorMessage = message;
return ApiResponse.failure(message);
} }
return ApiResponse.success(Map<String, dynamic>.from(response)); return ApiResponse.success(Map<String, dynamic>.from(response));
} catch (e) { } catch (e) {
logger.e(e); logger.e(e);
final message = _extractErrorMessage(e);
model.errorMessage = message;
model.isLoading = false; model.isLoading = false;
notifyListeners(); notifyListeners();
return ApiResponse.failure( return ApiResponse.failure(message);
"Problem during registration, please try again or contact support?",
);
} }
} }
Future<ApiResponse<Map<String, dynamic>>> resendOtp () async { Future<ApiResponse<Map<String, dynamic>>> resendOtp() async {
Map user = { Map user = {
"username": onboardingController.model.googleUser?.email, "username": onboardingController.model.email,
"phone": onboardingController.model.phone, "phone": onboardingController.model.phone,
"workflowId": onboardingController.model.workflowId, "workflowId": onboardingController.model.workflowId,
}; };
try { try {
model.isLoading = true; model.isLoading = true;
model.errorMessage = null;
notifyListeners(); notifyListeners();
Map response = await http.post( Map response = await http.post('/auth/resend', user);
'/auth/resend',
user
);
model.status = response['state']; model.status = response['state'];
model.isLoading = false; model.isLoading = false;
notifyListeners(); notifyListeners();
if(response['state'] == 'failed') { if (response['state'] == 'failed') {
model.errorMessage = response['message']; final message = response['message']?.toString() ?? "Resend failed";
return ApiResponse.failure(response['message'] ?? "Resend failed"); model.errorMessage = message;
return ApiResponse.failure(message);
} }
return ApiResponse.success(Map<String, dynamic>.from(response)); return ApiResponse.success(Map<String, dynamic>.from(response));
} catch (e) { } catch (e) {
logger.e(e); logger.e(e);
final message = _extractErrorMessage(e);
model.errorMessage = message;
model.isLoading = false; model.isLoading = false;
notifyListeners(); notifyListeners();
return ApiResponse.failure( return ApiResponse.failure(message);
"Problem during registration, please try again or contact support?",
);
} }
} }
} }

View File

@@ -1,7 +1,7 @@
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:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/onboarding/verify/verify_controller.dart'; import 'package:qpay/screens/onboarding/verify/verify_controller.dart';
import 'package:qpay/screens/onboarding/widgets/onboarding_layout.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
class VerifyScreen extends StatefulWidget { class VerifyScreen extends StatefulWidget {
@@ -18,7 +18,6 @@ class _VerifyScreenState extends State<VerifyScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
verifyController = VerifyController(context); verifyController = VerifyController(context);
} }
@@ -48,6 +47,37 @@ class _VerifyScreenState extends State<VerifyScreen> {
void _onCodeChanged(String value, int index) { void _onCodeChanged(String value, int index) {
if (value.length == 1 && index < 5) { if (value.length == 1 && index < 5) {
_focusNodes[index + 1].requestFocus(); _focusNodes[index + 1].requestFocus();
} else if (value.isEmpty && index > 0) {
_focusNodes[index - 1].requestFocus();
}
}
Future<void> _submitCode() async {
showErrorMessage(null);
String code = '';
for (var controller in _codeControllers) {
code += controller.text;
}
if (code.length != 6) {
showErrorMessage('Invalid code');
return;
}
verifyController.updateCode(code);
final response = await verifyController.verifyOtp();
if (!mounted) return;
if (response.isSuccess) {
if (verifyController.model.status == 'done') {
context.go('/onboarding/complete');
} else {
showErrorMessage(
verifyController.model.errorMessage ?? 'Verification failed',
);
}
} else {
showErrorMessage(
response.error ?? 'Verification failed. Please try again.',
);
} }
} }
@@ -56,170 +86,145 @@ class _VerifyScreenState extends State<VerifyScreen> {
return ListenableBuilder( return ListenableBuilder(
listenable: verifyController, listenable: verifyController,
builder: (context, child) { builder: (context, child) {
return Scaffold( return OnboardingLayout(
body: SingleChildScrollView( showBackButton: true,
child: Padding( trailingLabel: 'Sign in',
padding: const EdgeInsets.all(25), trailingRoute: '/onboarding/login',
child: Center( trailingIcon: Icons.login_rounded,
child: LayoutBuilder( bottomLinkLabel: 'Continue as guest?',
builder: (context, constraints) { bottomLinkRoute: '/',
final maxWidth = constraints.maxWidth > ResponsivePolicy.md child: OnboardingCard(
? ResponsivePolicy.md.toDouble() child: Column(
: constraints.maxWidth; crossAxisAlignment: CrossAxisAlignment.stretch,
return SizedBox( children: [
width: maxWidth, const OnboardingHero(
child: Column( icon: Icons.sms_rounded,
mainAxisAlignment: MainAxisAlignment.center, title: 'Verify your account',
crossAxisAlignment: CrossAxisAlignment.center, subtitle:
children: [ 'Enter the 6-digit code we sent to your phone or email.',
SizedBox(height: 75),
Text(
'Verify Your Account',
style: TextStyle(
fontSize: 30,
fontWeight: FontWeight.bold,
color: Theme.of(context).colorScheme.primary,
),
),
SizedBox(height: 5),
Text(
'Enter the 6-digit code sent to your phone/email',
style: TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
Image.asset('assets/verify.png', width: 300),
// Verification Code Input
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: List.generate(
6,
(index) => SizedBox(
width: 50,
child: TextFormField(
controller: _codeControllers[index],
focusNode: _focusNodes[index],
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
maxLength: 1,
decoration: InputDecoration(
counterText: '',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Theme.of(context).colorScheme.primary,
width: 2,
),
),
contentPadding: EdgeInsets.symmetric(vertical: 16),
),
onChanged: (value) => _onCodeChanged(value, index),
),
),
),
),
SizedBox(height: 5),
if(errorMessage != null)
Text(errorMessage ?? '', style: TextStyle(color: Colors.red)),
SizedBox(height: 30),
// Resend Code Section
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"Didn't receive the code? ",
style: TextStyle(color: Colors.grey.shade600, fontSize: 16),
),
TextButton(
onPressed: () {
verifyController.resendOtp();
},
child: Text(
'Resend Code',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
],
),
// SizedBox(height: 20),
// // Timer for resend (optional)
// Text(
// 'Resend available in 2:30',
// style: TextStyle(color: Colors.grey.shade500, fontSize: 14),
// ),
SizedBox(height: 40),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 25),
child: Skeletonizer(
enabled: verifyController.model.isLoading,
child: Row(
children: [
Expanded(
child: ElevatedButton(
onPressed: () async {
showErrorMessage(null);
String code = '';
for (var controller in _codeControllers) {
code += controller.text;
}
if(code.length != 6) {
showErrorMessage('Invalid code');
return;
}
verifyController.updateCode(code);
await verifyController.verifyOtp();
if(verifyController.model.status == 'done') {
context.go('/onboarding/complete');
}
},
child: Text(
'Verify Code',
style: TextStyle(fontSize: 16),
),
),
),
SizedBox(width: 16),
Expanded(
child: OutlinedButton(
onPressed: () {
context.go('/onboarding/login');
},
child: Text(
'Back to Login',
style: TextStyle(fontSize: 16),
),
),
),
],
),
),
),
SizedBox(height: 10),
TextButton(
onPressed: () {
context.go('/');
},
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
),
],
),
);
}
), ),
), const SizedBox(height: 32),
_buildCodeInputs(),
const SizedBox(height: 16),
if (errorMessage != null)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Center(
child: Text(
errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
),
const SizedBox(height: 8),
_buildResendRow(),
const SizedBox(height: 28),
Skeletonizer(
enabled: verifyController.model.isLoading,
child: OnboardingPrimaryButton(
label: 'Verify code',
icon: Icons.check_rounded,
onPressed: _submitCode,
),
),
],
), ),
), ),
); );
} },
);
}
Widget _buildCodeInputs() {
final colorScheme = Theme.of(context).colorScheme;
final borderRadius = BorderRadius.circular(14);
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: List.generate(6, (index) {
return SizedBox(
width: 48,
child: TextFormField(
controller: _codeControllers[index],
focusNode: _focusNodes[index],
textAlign: TextAlign.center,
keyboardType: TextInputType.number,
maxLength: 1,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: colorScheme.onSurface,
),
decoration: InputDecoration(
counterText: '',
filled: true,
fillColor: colorScheme.surface,
contentPadding: const EdgeInsets.symmetric(vertical: 14),
enabledBorder: OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(
color: colorScheme.onSurface.withValues(alpha: 0.12),
),
),
focusedBorder: OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(
color: colorScheme.primary,
width: 1.6,
),
),
errorBorder: OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(color: colorScheme.error),
),
focusedErrorBorder: OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(
color: colorScheme.error,
width: 1.6,
),
),
),
onChanged: (value) => _onCodeChanged(value, index),
),
);
}),
);
}
Widget _buildResendRow() {
final colorScheme = Theme.of(context).colorScheme;
return Center(
child: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
"Didn't receive the code?",
style: TextStyle(
color: colorScheme.onSurface.withValues(alpha: 0.65),
fontSize: 14,
),
),
TextButton(
onPressed: () {
verifyController.resendOtp();
},
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
padding: const EdgeInsets.symmetric(horizontal: 6),
textStyle: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
),
),
child: const Text('Resend code'),
),
],
),
); );
} }
} }

View File

@@ -0,0 +1,611 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:qpay/models/responsive_policy.dart';
/// Shared layout for the onboarding flow (login, register, verify, complete).
///
/// Provides the consistent Velocity look & feel:
/// - Soft brand gradient background
/// - Top bar with the Velocity brand mark and contextual trailing action
/// - Centered content card (form area)
/// - Bottom "Continue as guest?" + footer
///
/// All screens wrap their content with this layout so the flow feels cohesive
/// across mobile and web.
class OnboardingLayout extends StatelessWidget {
const OnboardingLayout({
super.key,
required this.child,
this.trailingLabel,
this.trailingRoute,
this.trailingIcon,
this.showBackButton = false,
this.onBack,
this.bottomLinkLabel,
this.bottomLinkRoute,
this.bottomLinkOnPressed,
});
/// The body of the screen (typically a form inside an [OnboardingCard]).
final Widget child;
/// Optional label for the trailing action in the top bar
/// (e.g. "Sign in", "Back"). Hidden if null.
final String? trailingLabel;
/// Route to navigate to when the trailing action is pressed.
final String? trailingRoute;
/// Optional icon for the trailing action (defaults to chevron/arrow).
final IconData? trailingIcon;
/// If true, shows a back-arrow icon button on the leading side of the top bar.
final bool showBackButton;
/// Optional callback for the back button. If not provided, the button pops
/// the route via [GoRouter].
final VoidCallback? onBack;
/// Optional label for a link rendered below the main content
/// (e.g. "Continue as guest?").
final String? bottomLinkLabel;
/// Route to navigate to when the bottom link is pressed.
final String? bottomLinkRoute;
/// Optional callback for the bottom link (overrides [bottomLinkRoute]).
final VoidCallback? bottomLinkOnPressed;
// Max content width on large screens (desktop / web) for the page chrome
// (top bar, footer, etc.).
static const double _maxContentWidth = 1200;
// Max content width on tablet — the current mobile cap lifted slightly
// so the page breathes a bit on landscape phones / small tablets.
static const double _maxTabletWidth = 760;
// Max width for the form card on desktop — keeps forms a comfortable
// reading/interaction width on wide screens.
static const double _formMaxWidth = 480;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final width = MediaQuery.of(context).size.width;
final isTablet = width >= ResponsivePolicy.md;
final isDesktop = width >= ResponsivePolicy.lg;
return Scaffold(
// Apply the gradient to the Scaffold body itself via a Container so
// it is guaranteed to cover the full viewport (including the area
// under the SafeArea) regardless of how tall the form content ends
// up being. The previous Stack + Positioned.fill version shrunk to
// fit the non-positioned SingleChildScrollView, which left an
// unstyled area at the bottom of the page.
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
colorScheme.primary.withValues(alpha: 0.08),
colorScheme.surface,
colorScheme.secondary.withValues(alpha: 0.05),
],
),
),
child: SafeArea(
child: LayoutBuilder(
builder: (context, viewportConstraints) {
final horizontalPadding = isDesktop ? 48.0 : 20.0;
final shellWidth = viewportConstraints.maxWidth < _maxContentWidth
? viewportConstraints.maxWidth
: _maxContentWidth;
final availableContentWidth =
(shellWidth - (horizontalPadding * 2)).clamp(
0.0,
double.infinity,
);
final contentMaxWidth = isDesktop
? _maxContentWidth
: (isTablet
? (_maxTabletWidth < availableContentWidth
? _maxTabletWidth
: availableContentWidth)
: availableContentWidth);
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: viewportConstraints.maxHeight,
),
child: IntrinsicHeight(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: _maxContentWidth,
),
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: horizontalPadding,
vertical: isDesktop ? 32 : 16,
),
child: SizedBox(
width: contentMaxWidth,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 4),
_buildTopBar(
context,
theme,
colorScheme,
isDesktop,
),
SizedBox(height: isDesktop ? 32 : 20),
// On desktop, narrow the form to a
// comfortable width and center it. On
// mobile/tablet, let it expand to fill
// the available space.
isDesktop
? Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: _formMaxWidth,
),
child: child,
),
)
: child,
if (bottomLinkLabel != null ||
bottomLinkOnPressed != null) ...[
const SizedBox(height: 16),
_buildBottomLink(context, theme, colorScheme),
],
const Spacer(),
_buildFooter(theme, colorScheme),
const SizedBox(height: 12),
],
),
),
),
),
),
),
),
);
},
),
),
),
);
}
Widget _buildTopBar(
BuildContext context,
ThemeData theme,
ColorScheme colorScheme,
bool isDesktop,
) {
return Row(
children: [
if (showBackButton)
IconButton(
onPressed:
onBack ??
() {
if (context.canPop()) {
context.pop();
} else {
context.go('/onboarding/landing');
}
},
icon: Icon(Icons.arrow_back_rounded, color: colorScheme.onSurface),
tooltip: 'Back',
style: IconButton.styleFrom(
backgroundColor: colorScheme.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(
color: colorScheme.onSurface.withValues(alpha: 0.08),
),
),
),
)
else
GestureDetector(
onTap: () => context.go('/onboarding/landing'),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: colorScheme.primary.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.bolt_rounded,
color: colorScheme.primary,
size: 24,
),
),
const SizedBox(width: 10),
Text(
'Velocity',
style: theme.textTheme.titleLarge?.copyWith(
color: colorScheme.primary,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
),
),
],
),
),
const Spacer(),
if (trailingLabel != null && trailingRoute != null)
TextButton(
onPressed: () => context.go(trailingRoute!),
style: TextButton.styleFrom(
foregroundColor: colorScheme.primary,
padding: EdgeInsets.symmetric(horizontal: isDesktop ? 16 : 8),
textStyle: const TextStyle(
fontWeight: FontWeight.w600,
fontSize: 14,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (trailingIcon != null) ...[
Icon(trailingIcon, size: 18),
const SizedBox(width: 6),
],
Text(trailingLabel!),
],
),
),
],
);
}
Widget _buildBottomLink(
BuildContext context,
ThemeData theme,
ColorScheme colorScheme,
) {
return Center(
child: TextButton(
onPressed:
bottomLinkOnPressed ??
() {
if (bottomLinkRoute != null) context.go(bottomLinkRoute!);
},
child: Text(
bottomLinkLabel ?? '',
style: TextStyle(
fontSize: 14,
color: colorScheme.onSurface.withValues(alpha: 0.7),
fontWeight: FontWeight.w500,
),
),
),
);
}
Widget _buildFooter(ThemeData theme, ColorScheme colorScheme) {
return Center(
child: Text(
'© Velocity · Fast, secure local payments',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.45),
letterSpacing: 0.2,
),
),
);
}
}
/// A rounded card used to wrap onboarding forms. Provides the matching
/// border, shadow, and inner padding used throughout the onboarding flow.
class OnboardingCard extends StatelessWidget {
const OnboardingCard({
super.key,
required this.child,
this.padding = const EdgeInsets.fromLTRB(24, 28, 24, 24),
});
final Widget child;
final EdgeInsetsGeometry padding;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
width: double.infinity,
padding: padding,
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: colorScheme.onSurface.withValues(alpha: 0.06),
width: 1,
),
boxShadow: [
BoxShadow(
color: colorScheme.primary.withValues(alpha: 0.06),
blurRadius: 24,
offset: const Offset(0, 12),
),
BoxShadow(
color: Colors.black.withValues(alpha: 0.03),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: child,
);
}
}
/// Hero section used at the top of onboarding cards: a tinted circular icon
/// badge plus a title and optional subtitle.
class OnboardingHero extends StatelessWidget {
const OnboardingHero({
super.key,
required this.icon,
required this.title,
this.subtitle,
this.accent,
});
final IconData icon;
final String title;
final String? subtitle;
final Color? accent;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final tint = accent ?? colorScheme.primary;
return Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: tint.withValues(alpha: 0.12),
shape: BoxShape.circle,
),
child: Icon(icon, color: tint, size: 32),
),
const SizedBox(height: 16),
Text(
title,
textAlign: TextAlign.center,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
color: colorScheme.onSurface,
letterSpacing: -0.5,
),
),
if (subtitle != null) ...[
const SizedBox(height: 6),
Text(
subtitle!,
textAlign: TextAlign.center,
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurface.withValues(alpha: 0.65),
height: 1.45,
),
),
],
],
);
}
}
/// Modern, theme-aligned text field used by the onboarding forms.
///
/// Wraps a [TextFormField] and applies the consistent border, prefix icon,
/// and focus styling that matches the rest of the Velocity design system.
class OnboardingTextField extends StatelessWidget {
const OnboardingTextField({
super.key,
required this.controller,
this.hintText,
this.labelText,
this.prefixIcon,
this.suffixIcon,
this.keyboardType,
this.textCapitalization = TextCapitalization.none,
this.autocorrect = false,
this.enableSuggestions = true,
this.obscureText = false,
this.validator,
this.onChanged,
});
final TextEditingController controller;
final String? hintText;
final String? labelText;
final IconData? prefixIcon;
final Widget? suffixIcon;
final TextInputType? keyboardType;
final TextCapitalization textCapitalization;
final bool autocorrect;
final bool enableSuggestions;
final bool obscureText;
final String? Function(String?)? validator;
final ValueChanged<String>? onChanged;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final borderRadius = BorderRadius.circular(14);
final enabledBorder = OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(
color: colorScheme.onSurface.withValues(alpha: 0.12),
width: 1,
),
);
final focusedBorder = OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(color: colorScheme.primary, width: 1.6),
);
final errorBorder = OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(color: colorScheme.error, width: 1),
);
final focusedErrorBorder = OutlineInputBorder(
borderRadius: borderRadius,
borderSide: BorderSide(color: colorScheme.error, width: 1.6),
);
return TextFormField(
controller: controller,
keyboardType: keyboardType,
textCapitalization: textCapitalization,
autocorrect: autocorrect,
enableSuggestions: enableSuggestions,
obscureText: obscureText,
validator: validator,
onChanged: onChanged,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 15,
fontWeight: FontWeight.w500,
),
decoration: InputDecoration(
hintText: hintText,
labelText: labelText,
prefixIcon: prefixIcon != null
? Icon(
prefixIcon,
color: colorScheme.onSurface.withValues(alpha: 0.55),
)
: null,
suffixIcon: suffixIcon,
filled: true,
fillColor: colorScheme.surface,
enabledBorder: enabledBorder,
focusedBorder: focusedBorder,
errorBorder: errorBorder,
focusedErrorBorder: focusedErrorBorder,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 16,
),
),
);
}
}
/// Primary CTA button used across the onboarding screens.
class OnboardingPrimaryButton extends StatelessWidget {
const OnboardingPrimaryButton({
super.key,
required this.label,
this.icon,
this.onPressed,
this.fullWidth = true,
this.height = 52,
});
final String label;
final IconData? icon;
final VoidCallback? onPressed;
final bool fullWidth;
final double height;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return SizedBox(
width: fullWidth ? double.infinity : null,
height: height,
child: ElevatedButton(
onPressed: onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: colorScheme.primary,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(label),
if (icon != null) ...[
const SizedBox(width: 8),
Icon(icon, size: 20),
],
],
),
),
);
}
}
/// Secondary outlined button used across the onboarding screens.
class OnboardingSecondaryButton extends StatelessWidget {
const OnboardingSecondaryButton({
super.key,
required this.label,
this.icon,
this.onPressed,
this.fullWidth = true,
this.height = 52,
});
final String label;
final IconData? icon;
final VoidCallback? onPressed;
final bool fullWidth;
final double height;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return SizedBox(
width: fullWidth ? double.infinity : null,
height: height,
child: OutlinedButton(
onPressed: onPressed,
style: OutlinedButton.styleFrom(
side: BorderSide(color: colorScheme.primary, width: 1.5),
foregroundColor: colorScheme.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: 20),
const SizedBox(width: 8),
],
Text(label),
],
),
),
);
}
}

View File

@@ -73,7 +73,9 @@ class PayController extends ChangeNotifier {
// formData can come from a repeat transaction // formData can come from a repeat transaction
// otherwise build it from elements of the normal flow // otherwise build it from elements of the normal flow
Future<ApiResponse<Map<String, dynamic>>> doTransaction([tc.FormData? formData]) async { Future<ApiResponse<Map<String, dynamic>>> doTransaction([
tc.FormData? formData,
]) async {
try { try {
model.isLoading = true; model.isLoading = true;
notifyListeners(); notifyListeners();
@@ -129,7 +131,9 @@ class PayController extends ChangeNotifier {
logger.e(s); logger.e(s);
model.isLoading = false; model.isLoading = false;
notifyListeners(); notifyListeners();
return ApiResponse.failure("Network error. Please try again or contact support"); return ApiResponse.failure(
"Network error. Please try again or contact support",
);
} catch (e, s) { } catch (e, s) {
logger.e(s); logger.e(s);
model.isLoading = false; model.isLoading = false;
@@ -170,8 +174,9 @@ class PayController extends ChangeNotifier {
notifyListeners(); notifyListeners();
List<dynamic> response = await http.get('/public/payment-processors'); List<dynamic> response = await http.get('/public/payment-processors');
model.paymentProcessors = model.paymentProcessors = response
response.map((e) => PaymentProcessor.fromJson(e)).toList(); .map((e) => PaymentProcessor.fromJson(e))
.toList();
model.isLoading = false; model.isLoading = false;
notifyListeners(); notifyListeners();

View File

@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/pay/pay_controller.dart'; import 'package:qpay/screens/pay/pay_controller.dart';
import 'package:qpay/screens/transaction_controller.dart'; import 'package:qpay/screens/transaction_controller.dart';
import 'package:qpay/widgets/app_snack_bar.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
@@ -65,7 +66,7 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
controller = PayController(context); controller = PayController(context);
controller.getPaymentProcessors(); controller.getPaymentProcessors();
controller.getProducts(controller.model.selectedProvider?.uid ?? ""); controller.getProducts(controller.model.selectedProvider?.id ?? "");
transactionController = Provider.of<TransactionController>( transactionController = Provider.of<TransactionController>(
context, context,
listen: false, listen: false,
@@ -169,17 +170,11 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
if (!apiResponse.isSuccess) { if (!apiResponse.isSuccess) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( AppSnackBar.showError(
SnackBar( context,
content: Text( controller.model.errorMessage ??
controller.model.errorMessage ?? apiResponse.error ??
apiResponse.error ?? "Transaction failed",
"Transaction failed",
),
behavior: SnackBarBehavior.floating,
width: 600,
backgroundColor: Colors.deepOrange,
),
); );
} }
} else if (controller.model.status == "SUCCESS" && context.mounted) { } else if (controller.model.status == "SUCCESS" && context.mounted) {
@@ -303,15 +298,46 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
if (showAdditionalRecipientDetails) if (showAdditionalRecipientDetails)
_buildAdditionalRecipientDetails(), _buildAdditionalRecipientDetails(),
const SizedBox(height: 7), const SizedBox(height: 7),
if (controller.model.products.isNotEmpty) if (controller.model.products.isNotEmpty ||
controller.model.isLoading)
_buildBillProductSelector(controller), _buildBillProductSelector(controller),
const SizedBox(height: 7), const SizedBox(height: 7),
_buildPhoneField(), _buildPhoneField(),
const SizedBox(height: 7), const SizedBox(height: 7),
_buildAmountField(), _buildAmountField(),
const SizedBox(height: 20), const SizedBox(height: 20),
...controller.model.paymentProcessors.map( LayoutBuilder(
(e) => _buildPaymentProcessorItem(e, context), builder: (context, wrapConstraints) {
final effectiveWidth =
constraints.maxWidth >
ResponsivePolicy.md
? ResponsivePolicy.md.toDouble()
: constraints.maxWidth;
final columns =
effectiveWidth > ResponsivePolicy.sm
? 2
: 1;
final itemWidth =
(effectiveWidth - (columns - 1) * 10) /
columns;
return Wrap(
spacing: 10,
runSpacing: 10,
children: controller
.model
.paymentProcessors
.map(
(e) => SizedBox(
width: itemWidth,
child: _buildPaymentProcessorItem(
e,
context,
),
),
)
.toList(),
);
},
), ),
], ],
), ),
@@ -720,77 +746,73 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
} }
Widget _buildPaymentProcessorItem(PaymentProcessor e, BuildContext context) { Widget _buildPaymentProcessorItem(PaymentProcessor e, BuildContext context) {
return Column( final rawIndex = controller.model.paymentProcessors.indexOf(e);
children: [ final animation = rawIndex < _animations.length
Skeletonizer( ? _animations[rawIndex]
enabled: controller.model.isLoading, : _animations.isNotEmpty
child: SlideTransition( ? _animations.last
position: : Tween<Offset>(
_animations[controller.model.paymentProcessors.indexOf(e)], begin: Offset.zero,
child: Container( end: Offset.zero,
decoration: BoxDecoration( ).animate(_controller);
border: Border.all(color: Colors.black87.withAlpha(20)), return Skeletonizer(
borderRadius: BorderRadius.circular(12), enabled: controller.model.isLoading,
gradient: LinearGradient( child: SlideTransition(
colors: [ position: animation,
Theme.of( child: Container(
context, decoration: BoxDecoration(
).colorScheme.primary.withValues(alpha: 0.1), border: Border.all(color: Colors.black87.withAlpha(20)),
Theme.of( borderRadius: BorderRadius.circular(12),
context, gradient: LinearGradient(
).colorScheme.tertiary.withValues(alpha: 0.05), colors: [
], Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
stops: const [0.3, 0.7], Theme.of(context).colorScheme.tertiary.withValues(alpha: 0.05),
begin: Alignment.topRight, ],
end: Alignment.bottomLeft, stops: const [0.3, 0.7],
), begin: Alignment.topRight,
), end: Alignment.bottomLeft,
child: InkWell( ),
customBorder: RoundedRectangleBorder( ),
borderRadius: BorderRadius.circular(12), child: InkWell(
), customBorder: RoundedRectangleBorder(
onTap: () async { borderRadius: BorderRadius.circular(12),
if (_formKey.currentState!.validate()) { ),
if (controller.model.isLoading) return; onTap: () async {
if (_formKey.currentState!.validate()) {
if (controller.model.isLoading) return;
await _submitPayment(e, context); await _submitPayment(e, context);
} }
}, },
child: ListTile( child: ListTile(
leading: Container( leading: Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of( color: Theme.of(
context, context,
).colorScheme.primary.withValues(alpha: 0.1), ).colorScheme.primary.withValues(alpha: 0.1),
shape: BoxShape.circle, shape: BoxShape.circle,
),
child: Image(image: AssetImage("assets/${e.image}")),
),
title: Text(
e.name,
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
e.description,
style: TextStyle(
fontWeight: FontWeight.normal,
fontSize: 10,
),
),
trailing: Icon(
Icons.chevron_right,
color: Theme.of(
context,
).colorScheme.secondary.withValues(alpha: 0.7),
),
), ),
child: Image(image: AssetImage("assets/${e.image}")),
),
title: Text(
e.name,
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: Text(
e.description,
style: TextStyle(fontWeight: FontWeight.normal, fontSize: 10),
),
trailing: Icon(
Icons.chevron_right,
color: Theme.of(
context,
).colorScheme.secondary.withValues(alpha: 0.7),
), ),
), ),
), ),
), ),
SizedBox(height: 10), ),
],
); );
} }
} }

View File

@@ -92,7 +92,9 @@ class _PollScreenState extends State<PollScreen> {
'successful. This won\'t take long.', 'successful. This won\'t take long.',
status: controller.model.status, status: controller.model.status,
isLoading: controller.model.isLoading, isLoading: controller.model.isLoading,
errorMessage: 'We failed to check your transaction status', errorMessage:
controller.model.errorMessage ??
'We failed to check your transaction status',
onRetry: () { onRetry: () {
controller.model.isCancelled = false; controller.model.isCancelled = false;
_startPolling(); _startPolling();

View File

@@ -259,7 +259,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
), ),
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 16),
// Animated info card section // Animated info card section
FadeTransition( FadeTransition(
@@ -272,7 +272,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
), ),
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 14),
// Animated content section // Animated content section
FadeTransition( FadeTransition(
@@ -386,7 +386,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
], ],
stops: const [0.0, 0.5, 1.0], stops: const [0.0, 0.5, 1.0],
), ),
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(20),
border: Border.all( border: Border.all(
color: primaryColor.withValues(alpha: 0.12), color: primaryColor.withValues(alpha: 0.12),
width: 1, width: 1,
@@ -399,13 +399,13 @@ class _ReceiptScreenState extends State<ReceiptScreen>
), ),
], ],
), ),
padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 24), padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20),
child: Column( child: Column(
children: [ children: [
// Status badge // Status badge
if (status != null) ...[ if (status != null) ...[
_buildStatusBadge(status, theme), _buildStatusBadge(status, theme),
const SizedBox(height: 16), const SizedBox(height: 12),
], ],
// Amount row // Amount row
@@ -433,14 +433,14 @@ class _ReceiptScreenState extends State<ReceiptScreen>
), ),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 8),
// Bill name (if available) // Bill name (if available)
if (billName.isNotEmpty) if (billName.isNotEmpty)
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 14, horizontal: 12,
vertical: 6, vertical: 4,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: primaryColor.withValues(alpha: 0.08), color: primaryColor.withValues(alpha: 0.08),
@@ -455,7 +455,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
), ),
), ),
), ),
const SizedBox(height: 6), const SizedBox(height: 4),
// Date // Date
if (createdAt != null) if (createdAt != null)
@@ -467,11 +467,11 @@ class _ReceiptScreenState extends State<ReceiptScreen>
color: isDark ? Colors.white54 : Colors.grey.shade500, color: isDark ? Colors.white54 : Colors.grey.shade500,
), ),
), ),
const SizedBox(height: 24), const SizedBox(height: 16),
// Divider with dots pattern // Divider with dots pattern
_buildDottedDivider(isDark), _buildDottedDivider(isDark),
const SizedBox(height: 20), const SizedBox(height: 14),
// Action buttons row // Action buttons row
_buildActionButtons(theme), _buildActionButtons(theme),
@@ -487,7 +487,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
Widget _buildDottedDivider(bool isDark) { Widget _buildDottedDivider(bool isDark) {
return Row( return Row(
children: List.generate( children: List.generate(
60, 40,
(index) => Expanded( (index) => Expanded(
child: Container( child: Container(
height: 1.5, height: 1.5,
@@ -508,8 +508,8 @@ class _ReceiptScreenState extends State<ReceiptScreen>
final paymentStatus = _transaction?.paymentStatus; final paymentStatus = _transaction?.paymentStatus;
return Wrap( return Wrap(
spacing: 12, spacing: 10,
runSpacing: 8, runSpacing: 6,
alignment: WrapAlignment.center, alignment: WrapAlignment.center,
children: [ children: [
_modernActionButton( _modernActionButton(
@@ -926,7 +926,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
], ],
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(14),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -954,7 +954,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
// Divider // Divider
Container( Container(
@@ -963,12 +963,12 @@ class _ReceiptScreenState extends State<ReceiptScreen>
? Colors.white.withValues(alpha: 0.06) ? Colors.white.withValues(alpha: 0.06)
: Colors.grey.shade200, : Colors.grey.shade200,
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
// Detail items // Detail items
...items.map( ...items.map(
(item) => Padding( (item) => Padding(
padding: const EdgeInsets.only(bottom: 14), padding: const EdgeInsets.only(bottom: 10),
child: _buildModernDetailRow( child: _buildModernDetailRow(
theme: theme, theme: theme,
isDark: isDark, isDark: isDark,
@@ -980,7 +980,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
// Total amount divider & total // Total amount divider & total
if (totalAmount != null) ...[ if (totalAmount != null) ...[
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 2),
child: Container( child: Container(
height: 1, height: 1,
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -993,7 +993,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
), ),
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 8),
Row( Row(
children: [ children: [
Container( Container(
@@ -1111,4 +1111,4 @@ class _DetailItem {
this.isWarning = false, this.isWarning = false,
this.trailing, this.trailing,
}); });
} }

View File

@@ -20,14 +20,14 @@ class RecipientModel {
@freezed @freezed
abstract class Recipient with _$Recipient { abstract class Recipient with _$Recipient {
const factory Recipient({ const factory Recipient({
String? uid, @JsonKey(includeIfNull: false) String? uid,
String? name, @JsonKey(includeIfNull: false) String? name,
String? email, @JsonKey(includeIfNull: false) String? email,
String? phoneNumber, @JsonKey(includeIfNull: false) String? phoneNumber,
String? address, @JsonKey(includeIfNull: false) String? address,
String? account, @JsonKey(includeIfNull: false) String? account,
String? initials, @JsonKey(includeIfNull: false) String? initials,
String? latestProviderLabel, @JsonKey(includeIfNull: false) String? latestProviderLabel,
}) = _Recipient; }) = _Recipient;
factory Recipient.fromJson(Map<String, dynamic> json) => factory Recipient.fromJson(Map<String, dynamic> json) =>
@@ -40,14 +40,15 @@ class RecipientsController extends ChangeNotifier {
BuildContext context; BuildContext context;
RecipientsController(this.context) { RecipientsController(this.context) {
model.selectedProvider = model.selectedProvider = Provider.of<TransactionController>(
Provider.of<TransactionController>( context,
context, listen: false,
listen: false, ).model.selectedProvider;
).model.selectedProvider;
} }
Future<ApiResponse<List<Recipient>>> getRecipients([Map<String, String>? params]) async { Future<ApiResponse<List<Recipient>>> getRecipients([
Map<String, String>? params,
]) async {
model.errorMessage = null; model.errorMessage = null;
model.isLoading = true; model.isLoading = true;
model.recipients = getDummyRecipients(); model.recipients = getDummyRecipients();

View File

@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
/// @nodoc /// @nodoc
mixin _$Recipient { mixin _$Recipient {
String? get uid; String? get name; String? get email; String? get phoneNumber; String? get address; String? get account; String? get initials; String? get latestProviderLabel; @JsonKey(includeIfNull: false) String? get uid;@JsonKey(includeIfNull: false) String? get name;@JsonKey(includeIfNull: false) String? get email;@JsonKey(includeIfNull: false) String? get phoneNumber;@JsonKey(includeIfNull: false) String? get address;@JsonKey(includeIfNull: false) String? get account;@JsonKey(includeIfNull: false) String? get initials;@JsonKey(includeIfNull: false) String? get latestProviderLabel;
/// Create a copy of Recipient /// Create a copy of Recipient
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -48,7 +48,7 @@ abstract mixin class $RecipientCopyWith<$Res> {
factory $RecipientCopyWith(Recipient value, $Res Function(Recipient) _then) = _$RecipientCopyWithImpl; factory $RecipientCopyWith(Recipient value, $Res Function(Recipient) _then) = _$RecipientCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel @JsonKey(includeIfNull: false) String? uid,@JsonKey(includeIfNull: false) String? name,@JsonKey(includeIfNull: false) String? email,@JsonKey(includeIfNull: false) String? phoneNumber,@JsonKey(includeIfNull: false) String? address,@JsonKey(includeIfNull: false) String? account,@JsonKey(includeIfNull: false) String? initials,@JsonKey(includeIfNull: false) String? latestProviderLabel
}); });
@@ -160,7 +160,7 @@ return $default(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel)? $default,{required TResult orElse(),}) {final _that = this; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function(@JsonKey(includeIfNull: false) String? uid, @JsonKey(includeIfNull: false) String? name, @JsonKey(includeIfNull: false) String? email, @JsonKey(includeIfNull: false) String? phoneNumber, @JsonKey(includeIfNull: false) String? address, @JsonKey(includeIfNull: false) String? account, @JsonKey(includeIfNull: false) String? initials, @JsonKey(includeIfNull: false) String? latestProviderLabel)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) { switch (_that) {
case _Recipient() when $default != null: case _Recipient() when $default != null:
return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address,_that.account,_that.initials,_that.latestProviderLabel);case _: return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address,_that.account,_that.initials,_that.latestProviderLabel);case _:
@@ -181,7 +181,7 @@ return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel) $default,) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function(@JsonKey(includeIfNull: false) String? uid, @JsonKey(includeIfNull: false) String? name, @JsonKey(includeIfNull: false) String? email, @JsonKey(includeIfNull: false) String? phoneNumber, @JsonKey(includeIfNull: false) String? address, @JsonKey(includeIfNull: false) String? account, @JsonKey(includeIfNull: false) String? initials, @JsonKey(includeIfNull: false) String? latestProviderLabel) $default,) {final _that = this;
switch (_that) { switch (_that) {
case _Recipient(): case _Recipient():
return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address,_that.account,_that.initials,_that.latestProviderLabel);case _: return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address,_that.account,_that.initials,_that.latestProviderLabel);case _:
@@ -201,7 +201,7 @@ return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel)? $default,) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function(@JsonKey(includeIfNull: false) String? uid, @JsonKey(includeIfNull: false) String? name, @JsonKey(includeIfNull: false) String? email, @JsonKey(includeIfNull: false) String? phoneNumber, @JsonKey(includeIfNull: false) String? address, @JsonKey(includeIfNull: false) String? account, @JsonKey(includeIfNull: false) String? initials, @JsonKey(includeIfNull: false) String? latestProviderLabel)? $default,) {final _that = this;
switch (_that) { switch (_that) {
case _Recipient() when $default != null: case _Recipient() when $default != null:
return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address,_that.account,_that.initials,_that.latestProviderLabel);case _: return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address,_that.account,_that.initials,_that.latestProviderLabel);case _:
@@ -216,17 +216,17 @@ return $default(_that.uid,_that.name,_that.email,_that.phoneNumber,_that.address
@JsonSerializable() @JsonSerializable()
class _Recipient implements Recipient { class _Recipient implements Recipient {
const _Recipient({this.uid, this.name, this.email, this.phoneNumber, this.address, this.account, this.initials, this.latestProviderLabel}); const _Recipient({@JsonKey(includeIfNull: false) this.uid, @JsonKey(includeIfNull: false) this.name, @JsonKey(includeIfNull: false) this.email, @JsonKey(includeIfNull: false) this.phoneNumber, @JsonKey(includeIfNull: false) this.address, @JsonKey(includeIfNull: false) this.account, @JsonKey(includeIfNull: false) this.initials, @JsonKey(includeIfNull: false) this.latestProviderLabel});
factory _Recipient.fromJson(Map<String, dynamic> json) => _$RecipientFromJson(json); factory _Recipient.fromJson(Map<String, dynamic> json) => _$RecipientFromJson(json);
@override final String? uid; @override@JsonKey(includeIfNull: false) final String? uid;
@override final String? name; @override@JsonKey(includeIfNull: false) final String? name;
@override final String? email; @override@JsonKey(includeIfNull: false) final String? email;
@override final String? phoneNumber; @override@JsonKey(includeIfNull: false) final String? phoneNumber;
@override final String? address; @override@JsonKey(includeIfNull: false) final String? address;
@override final String? account; @override@JsonKey(includeIfNull: false) final String? account;
@override final String? initials; @override@JsonKey(includeIfNull: false) final String? initials;
@override final String? latestProviderLabel; @override@JsonKey(includeIfNull: false) final String? latestProviderLabel;
/// Create a copy of Recipient /// Create a copy of Recipient
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@@ -261,7 +261,7 @@ abstract mixin class _$RecipientCopyWith<$Res> implements $RecipientCopyWith<$Re
factory _$RecipientCopyWith(_Recipient value, $Res Function(_Recipient) _then) = __$RecipientCopyWithImpl; factory _$RecipientCopyWith(_Recipient value, $Res Function(_Recipient) _then) = __$RecipientCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
String? uid, String? name, String? email, String? phoneNumber, String? address, String? account, String? initials, String? latestProviderLabel @JsonKey(includeIfNull: false) String? uid,@JsonKey(includeIfNull: false) String? name,@JsonKey(includeIfNull: false) String? email,@JsonKey(includeIfNull: false) String? phoneNumber,@JsonKey(includeIfNull: false) String? address,@JsonKey(includeIfNull: false) String? account,@JsonKey(includeIfNull: false) String? initials,@JsonKey(includeIfNull: false) String? latestProviderLabel
}); });

View File

@@ -19,12 +19,12 @@ _Recipient _$RecipientFromJson(Map<String, dynamic> json) => _Recipient(
Map<String, dynamic> _$RecipientToJson(_Recipient instance) => Map<String, dynamic> _$RecipientToJson(_Recipient instance) =>
<String, dynamic>{ <String, dynamic>{
'uid': instance.uid, 'uid': ?instance.uid,
'name': instance.name, 'name': ?instance.name,
'email': instance.email, 'email': ?instance.email,
'phoneNumber': instance.phoneNumber, 'phoneNumber': ?instance.phoneNumber,
'address': instance.address, 'address': ?instance.address,
'account': instance.account, 'account': ?instance.account,
'initials': instance.initials, 'initials': ?instance.initials,
'latestProviderLabel': instance.latestProviderLabel, 'latestProviderLabel': ?instance.latestProviderLabel,
}; };

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:gif_view/gif_view.dart'; import 'package:gif_view/gif_view.dart';
import 'package:qpay/main.dart'; import 'package:qpay/routes.dart';
class SplashScreen extends StatefulWidget { class SplashScreen extends StatefulWidget {
const SplashScreen({super.key}); const SplashScreen({super.key});

View File

@@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
/// Centralized helper for showing SnackBars across the app.
///
/// This guarantees that every SnackBar has a fixed width of 400 and a
/// consistent floating behaviour, so the project-wide limit only has to
/// be set in one place.
class AppSnackBar {
AppSnackBar._();
/// The default width applied to every floating SnackBar in the app.
static const double width = 400;
/// Shows a SnackBar with the project-wide width of 400.
///
/// By default a [Text] widget with [message] is used as the content.
/// Pass [customContent] to override the default content widget (e.g. for
/// custom layouts that include icons, multiple lines, etc.).
static void show(
BuildContext context,
String message, {
Widget? customContent,
Color? backgroundColor,
Color? textColor,
SnackBarBehavior behavior = SnackBarBehavior.floating,
SnackBarAction? action,
Duration duration = const Duration(seconds: 4),
ShapeBorder? shape,
EdgeInsetsGeometry? margin,
EdgeInsetsGeometry? padding,
}) {
final messenger = ScaffoldMessenger.maybeOf(context);
if (messenger == null) return;
final Widget content = customContent ??
(textColor != null
? Text(message, style: TextStyle(color: textColor))
: Text(message));
messenger.showSnackBar(
SnackBar(
content: content,
backgroundColor: backgroundColor,
behavior: behavior,
action: action,
duration: duration,
shape: shape,
margin: margin,
padding: padding,
width: width,
),
);
}
/// Convenience helper for error messages.
static void showError(BuildContext context, String message) {
show(
context,
message,
backgroundColor: Colors.deepOrange,
);
}
/// Convenience helper for success messages.
static void showSuccess(BuildContext context, String message) {
show(
context,
message,
backgroundColor: Colors.green.shade700,
);
}
/// Convenience helper for informational messages.
static void showInfo(BuildContext context, String message) {
show(context, message);
}
}

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
class StatusChip extends StatelessWidget {
final String status;
const StatusChip({super.key, required this.status});
static const _successStatuses = {'SUCCESS', 'COMPLETE', 'COMPLETED'};
static const _processingStatuses = {'PROCESSING', 'REQUESTED', 'CONFIRMING'};
static const _pendingStatuses = {'CREATED', 'CONFIRMED_ALL'};
Map<String, dynamic> _getStatusColors() {
final upper = status.toUpperCase();
final bool isSuccess = _successStatuses.contains(upper);
final bool isProcessing = _processingStatuses.contains(upper);
final bool isPending = _pendingStatuses.contains(upper);
final Color bgColor = isSuccess
? const Color(0xFFD1FAE5)
: isProcessing
? const Color(0xFFDBEAFE)
: isPending
? const Color(0xFFFEF3C7)
: const Color(0xFFFEE2E2);
final Color textColor = isSuccess
? const Color(0xFF065F46)
: isProcessing
? const Color(0xFF1E40AF)
: isPending
? const Color(0xFF92400E)
: const Color(0xFF991B1B);
final Color dotColor = isSuccess
? const Color(0xFF10B981)
: isProcessing
? const Color(0xFF3B82F6)
: isPending
? const Color(0xFFF59E0B)
: const Color(0xFFEF4444);
return {'bgColor': bgColor, 'textColor': textColor, 'dotColor': dotColor};
}
@override
Widget build(BuildContext context) {
final colors = _getStatusColors();
final bgColor = colors['bgColor'] as Color;
final textColor = colors['textColor'] as Color;
final dotColor = colors['dotColor'] as Color;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle),
),
const SizedBox(width: 6),
Text(
status,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: textColor,
),
),
],
),
);
}
}

View File

@@ -93,15 +93,16 @@ class _StepLoadingWidgetState extends State<StepLoadingWidget>
const SizedBox(height: 60), const SizedBox(height: 60),
// Animated loader // Animated loader
if (widget.status != 'FAILED' && if (widget.status != 'FAILED' && widget.status != 'SUCCESS')
widget.status != 'SUCCESS')
AnimatedBuilder( AnimatedBuilder(
animation: Listenable.merge( animation: Listenable.merge([
[_pulseController, _spinController]), _pulseController,
_spinController,
]),
builder: (context, child) { builder: (context, child) {
return SizedBox( return SizedBox(
width: 120, width: 80,
height: 120, height: 80,
child: CustomPaint( child: CustomPaint(
painter: _LoadingPainter( painter: _LoadingPainter(
pulseValue: _pulseAnimation.value, pulseValue: _pulseAnimation.value,
@@ -113,11 +114,9 @@ class _StepLoadingWidgetState extends State<StepLoadingWidget>
}, },
), ),
if (widget.status == 'SUCCESS') if (widget.status == 'SUCCESS') _buildCheckIcon(primaryColor),
_buildCheckIcon(primaryColor),
if (widget.status == 'FAILED') if (widget.status == 'FAILED') _buildErrorIcon(theme),
_buildErrorIcon(theme),
const SizedBox(height: 40), const SizedBox(height: 40),
@@ -152,8 +151,7 @@ class _StepLoadingWidgetState extends State<StepLoadingWidget>
Column( Column(
children: [ children: [
Text( Text(
widget.errorMessage ?? widget.errorMessage ?? 'Something went wrong',
'Something went wrong',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: theme.colorScheme.error, color: theme.colorScheme.error,
@@ -172,10 +170,7 @@ class _StepLoadingWidgetState extends State<StepLoadingWidget>
horizontal: 32, horizontal: 32,
vertical: 14, vertical: 14,
), ),
side: BorderSide( side: BorderSide(color: primaryColor, width: 1.5),
color: primaryColor,
width: 1.5,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
@@ -195,24 +190,20 @@ class _StepLoadingWidgetState extends State<StepLoadingWidget>
Widget _buildCheckIcon(Color primaryColor) { Widget _buildCheckIcon(Color primaryColor) {
return Container( return Container(
width: 120, width: 80,
height: 120, height: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: primaryColor.withValues(alpha: 0.1), color: primaryColor.withValues(alpha: 0.1),
), ),
child: Icon( child: Icon(Icons.check_circle_rounded, size: 80, color: primaryColor),
Icons.check_circle_rounded,
size: 80,
color: primaryColor,
),
); );
} }
Widget _buildErrorIcon(ThemeData theme) { Widget _buildErrorIcon(ThemeData theme) {
return Container( return Container(
width: 120, width: 80,
height: 120, height: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: theme.colorScheme.error.withValues(alpha: 0.1), color: theme.colorScheme.error.withValues(alpha: 0.1),
@@ -264,9 +255,7 @@ class _StepIndicator extends StatelessWidget {
height: 2, height: 2,
margin: const EdgeInsets.symmetric(horizontal: 8), margin: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: currentStep >= 2 color: currentStep >= 2 ? primaryColor : Colors.grey.shade300,
? primaryColor
: Colors.grey.shade300,
borderRadius: BorderRadius.circular(1), borderRadius: BorderRadius.circular(1),
), ),
), ),
@@ -323,10 +312,7 @@ class _StepIndicator extends StatelessWidget {
Container( Container(
width: 32, width: 32,
height: 32, height: 32,
decoration: BoxDecoration( decoration: BoxDecoration(shape: BoxShape.circle, color: circleColor),
shape: BoxShape.circle,
color: circleColor,
),
child: Center(child: circleChild), child: Center(child: circleChild),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -335,9 +321,7 @@ class _StepIndicator extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal, fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
color: isActive color: isActive ? theme.colorScheme.tertiary : Colors.grey.shade500,
? theme.colorScheme.tertiary
: Colors.grey.shade500,
), ),
), ),
], ],
@@ -404,4 +388,4 @@ class _LoadingPainter extends CustomPainter {
return oldDelegate.pulseValue != pulseValue || return oldDelegate.pulseValue != pulseValue ||
oldDelegate.spinValue != spinValue; oldDelegate.spinValue != spinValue;
} }
} }

View File

@@ -40,11 +40,11 @@ static void my_application_activate(GApplication* application) {
if (use_header_bar) { if (use_header_bar) {
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
gtk_widget_show(GTK_WIDGET(header_bar)); gtk_widget_show(GTK_WIDGET(header_bar));
gtk_header_bar_set_title(header_bar, "qpay"); gtk_header_bar_set_title(header_bar, "Velocity Pay");
gtk_header_bar_set_show_close_button(header_bar, TRUE); gtk_header_bar_set_show_close_button(header_bar, TRUE);
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
} else { } else {
gtk_window_set_title(window, "qpay"); gtk_window_set_title(window, "Velocity Pay");
} }
gtk_window_set_default_size(window, 1280, 720); gtk_window_set_default_size(window, 1280, 720);

View File

@@ -6,7 +6,6 @@ import FlutterMacOS
import Foundation import Foundation
import firebase_core import firebase_core
import google_sign_in_ios
import path_provider_foundation import path_provider_foundation
import share_plus import share_plus
import shared_preferences_foundation import shared_preferences_foundation
@@ -15,7 +14,6 @@ import webview_flutter_wkwebview
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))

38
nginx.conf Normal file
View File

@@ -0,0 +1,38 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
gzip_min_length 1000;
# Cache static assets
location ~* \.(?:ico|css|js|gif|jpe?g|png|woff2?|eot|ttf|svg|json)$ {
expires 6M;
access_log off;
add_header Cache-Control "public, immutable";
}
# Service worker - no cache
location = /flutter_service_worker.js {
expires -1;
add_header Cache-Control "no-store, no-cache, must-revalidate";
}
# SPA fallback: all requests that don't match a real file go to index.html
location / {
try_files $uri $uri/ /index.html;
}
}
}

View File

@@ -237,26 +237,26 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: firebase_core name: firebase_core
sha256: "132e1c311bc41e7d387b575df0aacdf24efbf4930365eb61042be5bde3978f03" sha256: ec46a100a560d3bd5f97f2d89ba7492cb09b6dd0a4a28753d1258f360d6bd9f9
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "4.2.0" version: "4.10.0"
firebase_core_platform_interface: firebase_core_platform_interface:
dependency: transitive dependency: transitive
description: description:
name: firebase_core_platform_interface name: firebase_core_platform_interface
sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64 sha256: "4a120366dbf7d5a8ee9438978530b664b855728fb8dcc3a201017660817e555b"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.0.2" version: "7.0.1"
firebase_core_web: firebase_core_web:
dependency: transitive dependency: transitive
description: description:
name: firebase_core_web name: firebase_core_web
sha256: ecde2def458292404a4fcd3731ee4992fd631a0ec359d2d67c33baa8da5ec8ae sha256: "5ad1be848692ec148f2d6a8ad2a3838cb852ea5f3c9e6479a7afce479e1854f8"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.2.0" version: "3.8.0"
fixnum: fixnum:
dependency: transitive dependency: transitive
description: description:
@@ -360,54 +360,6 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "6.3.2" version: "6.3.2"
google_identity_services_web:
dependency: transitive
description:
name: google_identity_services_web
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
url: "https://pub.dev"
source: hosted
version: "0.3.3+1"
google_sign_in:
dependency: "direct main"
description:
name: google_sign_in
sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763"
url: "https://pub.dev"
source: hosted
version: "7.2.0"
google_sign_in_android:
dependency: transitive
description:
name: google_sign_in_android
sha256: "799165f4c0621ed233bccdded4c2e92739bc1fe73e970163b2f7493b301adad3"
url: "https://pub.dev"
source: hosted
version: "7.2.1"
google_sign_in_ios:
dependency: transitive
description:
name: google_sign_in_ios
sha256: d9d80f953a244a099a40df1ff6aadc10ee375e6a098bbd5d55be332ce26db18c
url: "https://pub.dev"
source: hosted
version: "6.2.1"
google_sign_in_platform_interface:
dependency: transitive
description:
name: google_sign_in_platform_interface
sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5"
url: "https://pub.dev"
source: hosted
version: "3.1.0"
google_sign_in_web:
dependency: "direct main"
description:
name: google_sign_in_web
sha256: "2fc1f941e6443b2d6984f4056a727a3eaeab15d8ee99ba7125d79029be75a1da"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
graphs: graphs:
dependency: transitive dependency: transitive
description: description:
@@ -728,6 +680,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.2.3" version: "2.2.3"
rename_app:
dependency: "direct dev"
description:
name: rename_app
sha256: "9df79b531a59124f3ec60316385809f79835eccf934a1ca7051f602a988acade"
url: "https://pub.dev"
source: hosted
version: "1.6.6"
share_plus: share_plus:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@@ -53,12 +53,10 @@ dependencies:
share_plus: ^11.0.0 share_plus: ^11.0.0
flutter_contacts: ^1.1.9+2 flutter_contacts: ^1.1.9+2
gif_view: ^0.4.0 gif_view: ^0.4.0
google_sign_in: ^7.1.1
firebase_core: ^4.1.0 firebase_core: ^4.1.0
url_launcher: ^6.3.2 url_launcher: ^6.3.2
html: ^0.15.6 html: ^0.15.6
web: ^1.1.1 web: ^1.1.1
google_sign_in_web: ^1.1.0
flutter_web_plugins: flutter_web_plugins:
sdk: flutter sdk: flutter
decimal: ^3.2.4 decimal: ^3.2.4
@@ -77,6 +75,7 @@ dev_dependencies:
build_runner: ^2.5.4 build_runner: ^2.5.4
freezed: ^3.0.6 freezed: ^3.0.6
json_serializable: ^6.9.5 json_serializable: ^6.9.5
rename_app: ^1.6.6
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec
@@ -128,3 +127,6 @@ icons_launcher:
enable: true enable: true
web: web:
enable: true enable: true
config_name:
name: "Velocity Pay"

BIN
upload-keystore.jks Normal file

Binary file not shown.

View File

@@ -1,35 +1,35 @@
{ {
"name": "Velocity", "name": "Velocity Pay",
"short_name": "Velocity", "short_name": "Velocity Pay",
"start_url": ".", "start_url": ".",
"display": "standalone", "display": "standalone",
"background_color": "#ffffff", "background_color": "#ffffff",
"theme_color": "#ffffff", "theme_color": "#ffffff",
"description": "Payments... but faster", "description": "Payments... but faster",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"prefer_related_applications": false, "prefer_related_applications": false,
"icons": [ "icons": [
{ {
"src": "icons/Icon-192.png", "src": "icons/Icon-192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "icons/Icon-512.png", "src": "icons/Icon-512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png" "type": "image/png"
}, },
{ {
"src": "icons/Icon-maskable-192.png", "src": "icons/Icon-maskable-192.png",
"sizes": "192x192", "sizes": "192x192",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
}, },
{ {
"src": "icons/Icon-maskable-512.png", "src": "icons/Icon-maskable-512.png",
"sizes": "512x512", "sizes": "512x512",
"type": "image/png", "type": "image/png",
"purpose": "maskable" "purpose": "maskable"
} }
] ]
} }

View File

@@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
FlutterWindow window(project); FlutterWindow window(project);
Win32Window::Point origin(10, 10); Win32Window::Point origin(10, 10);
Win32Window::Size size(1280, 720); Win32Window::Size size(1280, 720);
if (!window.Create(L"qpay", origin, size)) { if (!window.Create(L"Velocity Pay", origin, size)) {
return EXIT_FAILURE; return EXIT_FAILURE;
} }
window.SetQuitOnClose(true); window.SetQuitOnClose(true);