Compare commits
10 Commits
c86c7fca49
...
2bcee59fd7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bcee59fd7 | ||
|
|
ad79bf2af8 | ||
| b10d233428 | |||
| 0a267bdbb8 | |||
| fc616d6316 | |||
| 2dd790fe6e | |||
| eab2368331 | |||
| 1d8ab2088c | |||
| 7b5b962fe6 | |||
| 59c96d889a |
@@ -42,6 +42,10 @@ FROM nginx:1.25.2-alpine
|
||||
# copy the info of the builded web app to nginx
|
||||
COPY --from=build-env /app/build/web /usr/share/nginx/html
|
||||
|
||||
# Remove default nginx configuration and copy custom one
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
|
||||
# Expose and run nginx
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
7
No file path - using task_progress only
Normal file
7
No file path - using task_progress only
Normal file
@@ -0,0 +1,7 @@
|
||||
- [ ] Analyze history_controller and add support for filter (status, type, date) + bulk select
|
||||
- [ ] Update history_screen to mirror group detail list layout (search + filter + select + refresh + new buttons)
|
||||
- [ ] Add filter bottom sheet for transactions (status/type)
|
||||
- [ ] Add bulk action bar (Repeat selected, Delete selected) when select mode is on
|
||||
- [ ] Add scroll-to-load-more pagination like group detail
|
||||
- [ ] Tone down animation (short, subtle fade/slide)
|
||||
- [ ] Verify compile with `flutter analyze`
|
||||
4225
QPay.postman_collection.json
Normal file
4225
QPay.postman_collection.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,6 @@
|
||||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// START: FlutterFire Configuration
|
||||
@@ -8,6 +11,14 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
|
||||
val keystoreProperties = Properties()
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
|
||||
android {
|
||||
namespace = "zw.co.qantra.qpay"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
@@ -34,11 +45,21 @@ android {
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
// signingConfig = signingConfigs.getByName("debug")
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,16 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
|
||||
<application
|
||||
android:label="Velocity"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:forceDarkAllowed="false">
|
||||
<application android:label="Velocity Pay" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" android:forceDarkAllowed="false">
|
||||
|
||||
<!-- Main Activity -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:taskAffinity="" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<meta-data android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme"/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
@@ -33,9 +18,7 @@
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<meta-data android:name="flutterEmbedding" android:value="2"/>
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
||||
@@ -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://173.212.247.232:6950/api
|
||||
; BASE_URL=http://10.69.5.201:6950/api
|
||||
|
||||
@@ -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
|
||||
SIMULATE_PAYMENT_SUCCESS=false
|
||||
GATEWAY_SCRIPT_URL=https://na.gateway.mastercard.com/static/checkout/checkout.min.js
|
||||
|
||||
BIN
assets/city.png
Normal file
BIN
assets/city.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
assets/complete.png
Normal file
BIN
assets/complete.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
BIN
assets/signup.png
Normal file
BIN
assets/signup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
83
group-batches.md
Normal file
83
group-batches.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# Plan: Groups & Batches Feature
|
||||
|
||||
## API Surface (from Postman)
|
||||
|
||||
### Groups
|
||||
- `GET /api/public/recipient-groups?userId={userId}` — list groups
|
||||
- `GET /api/public/recipient-groups/members?recipientGroupId={id}` — list members
|
||||
- `POST /api/public/recipient-groups` — create group
|
||||
- Body: `{ name, description, userId, recipients: [{name, phoneNumber, latestProviderLabel, account}] }`
|
||||
|
||||
### Batches
|
||||
- `GET /api/public/group-batches?recipientGroupId={id}&sort=createdAt,desc` — list batches
|
||||
- `GET /api/public/group-batches/items?groupBatchId={id}&sort=createdAt,desc` — list batch items
|
||||
- `POST /api/public/group-batches` — create batch (recipientGroupId, userId, currency, amount, paymentProcessorLabel, paymentProcessorName, paymentProcessorImage, transactionTemplate)
|
||||
- `POST /api/public/group-batches/{batchId}/confirm` — confirm batch
|
||||
- `POST /api/public/group-batches/{batchId}/request` — request batch (payment)
|
||||
- `POST /api/public/group-batches/{batchId}/poll` — poll batch status
|
||||
|
||||
## Files To Create
|
||||
|
||||
### Phase 1: Models
|
||||
- `lib/models/recipient_group_model.dart` — RecipientGroup, RecipientMember, CreateGroupRequest
|
||||
- `lib/models/group_batch_model.dart` — GroupBatch, GroupBatchItem, CreateBatchRequest (transactionTemplate typed as TransactionModel from lib/screens/transactions/transaction_model.dart), BatchActionRequest. No separate TransactionTemplate class needed.
|
||||
|
||||
### Phase 2: Controllers
|
||||
- `lib/screens/groups/group_controller.dart` — ChangeNotifier, list/create groups, list members
|
||||
- `lib/screens/groups/batch_controller.dart` — ChangeNotifier, list/create batches, list items, confirm/request/poll
|
||||
|
||||
### Phase 3: Screens
|
||||
- `lib/screens/groups/groups_screen.dart` — list all groups
|
||||
- `lib/screens/groups/group_create_screen.dart` — create group form (name, description, multiline textarea: one recipient per line as `name,account`; phoneNumber defaults to account value)
|
||||
- `lib/screens/groups/group_detail_screen.dart` — tabbed view: Batches first (default), Members second; receives RecipientGroup via extra; FAB on Batches tab to create new batch
|
||||
- `lib/screens/groups/batch_create_screen.dart` — create batch form (provider, processor, amount, currency); on success navigate to BatchDetailScreen passing created batch via extra
|
||||
- `lib/screens/groups/batch_detail_screen.dart` — batch items list + Confirm/Request/Poll action buttons
|
||||
- `lib/screens/groups/batch_item_screen.dart` — single batch item detail view
|
||||
|
||||
### Phase 4: Integration
|
||||
- `lib/navbar.dart` — add Groups as 3rd nav item (index 2)
|
||||
- `lib/main.dart` — add routes + GroupController + BatchController providers
|
||||
|
||||
## Routes
|
||||
- `/groups` → GroupsScreen
|
||||
- `/groups/create` → GroupCreateScreen
|
||||
- `/groups/:groupId` → GroupDetailScreen
|
||||
- `/groups/:groupId/batches/create` → BatchCreateScreen
|
||||
- `/groups/:groupId/batches/:batchId` → BatchDetailScreen
|
||||
- `/groups/:groupId/batches/:batchId/items/:itemId` → BatchItemScreen
|
||||
|
||||
## Action Return Types
|
||||
- `createBatch` → `ApiResponse<GroupBatch>`
|
||||
- `confirmBatch` → `ApiResponse<GroupBatch>`
|
||||
- `requestBatch` → `ApiResponse<TransactionModel>` (reuses TransactionModel)
|
||||
- `pollBatch` → `ApiResponse<GroupBatch>`
|
||||
- List endpoints → `ApiResponse<PageableModel<T>>`
|
||||
|
||||
## BatchDetailScreen Auto-Poll Logic
|
||||
After **Request** action:
|
||||
1. `requestBatch()` returns `TransactionModel`
|
||||
2. Check `transaction.pollingStatus` — if successful (e.g. `SUCCESS`, `COMPLETE`) → no poll needed, refresh batch
|
||||
3. Otherwise → auto-trigger `pollBatch()` immediately without user input
|
||||
4. If auto-poll fails (network error / non-terminal status) → show **Poll** button for manual retry
|
||||
5. Batch status from poll response drives UI state (show/hide action buttons)
|
||||
|
||||
## Key Patterns
|
||||
- Controllers extend ChangeNotifier
|
||||
- Models use `@JsonSerializable` with generated `.g.dart` files
|
||||
- All network calls use `ApiResponse<T>` wrapping pattern (try/catch in controller)
|
||||
- Use `ListenableBuilder` in screens
|
||||
- Read `userId`/`token` from SharedPreferences
|
||||
- Pass extra data via GoRouter `extra:` parameter
|
||||
- `idempotencyKey` for batch actions generated as `"batch-{batchId}-{action}-{timestamp}"`
|
||||
- Navbar: add Groups at index 2, update `_handleTap` + `_calculateSelectedIndex`
|
||||
|
||||
## Member Input Parsing (group_create_screen)
|
||||
- One recipient per line: `name,account`
|
||||
- `phoneNumber` defaults to `account` value
|
||||
- `latestProviderLabel` defaults to `""`
|
||||
|
||||
## Decisions
|
||||
- `TransactionModel` reused as-is for `transactionTemplate` field — no wrapper class
|
||||
- No pagination on groups/batches lists in this iteration
|
||||
- No edit/delete for groups or batches in scope
|
||||
- Poll button only shown as manual fallback after auto-poll failure
|
||||
@@ -1,64 +1,63 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Velocity</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Velocity</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Velocity Pay</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Velocity Pay</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>$(FLUTTER_BUILD_NAME)</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>$(FLUTTER_BUILD_NUMBER)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>UILaunchStoryboardName</key>
|
||||
<string>LaunchScreen</string>
|
||||
<key>UIMainStoryboardFile</key>
|
||||
<string>Main</string>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||
<true/>
|
||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSContactsUsageDescription</key>
|
||||
<string>To fetch recipient phone numbers</string>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Light</string>
|
||||
|
||||
</dict>
|
||||
<key>NSContactsUsageDescription</key>
|
||||
<string>To fetch recipient phone numbers</string>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Light</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,16 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||
|
||||
class AbstractController extends ChangeNotifier {
|
||||
late BuildContext context;
|
||||
void showErrorSnackBar(String? message) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message.toString()),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Colors.deepOrange,
|
||||
),
|
||||
);
|
||||
AppSnackBar.showError(context, message.toString());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
87
lib/auth_state.dart
Normal file
87
lib/auth_state.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Tracks the user's authentication state so the [GoRouter] can decide
|
||||
/// whether to allow access to protected routes (such as the groups
|
||||
/// flow) or redirect to the sign-in screen.
|
||||
///
|
||||
/// The router is wired to refresh whenever [notifyListeners] is called,
|
||||
/// which means any place that mutates the auth state (login, logout,
|
||||
/// registration completion) just has to call [refresh] to keep the
|
||||
/// navigation layer in sync.
|
||||
class AuthState extends ChangeNotifier {
|
||||
/// SharedPreferences key used to persist the auth token returned by
|
||||
/// the backend after a successful sign-in.
|
||||
static const String _tokenKey = 'token';
|
||||
|
||||
bool _isLoggedIn = false;
|
||||
bool _initialized = false;
|
||||
|
||||
/// The route the user originally tried to visit before being sent to
|
||||
/// the sign-in screen. After a successful login, the redirect logic
|
||||
/// reads and clears this so the user lands where they were heading.
|
||||
String? _intendedRoute;
|
||||
|
||||
bool get isLoggedIn => _isLoggedIn;
|
||||
|
||||
String? get intendedRoute => _intendedRoute;
|
||||
|
||||
/// Loads the current auth state from disk. Safe to call multiple
|
||||
/// times; subsequent calls just re-read the token value.
|
||||
Future<void> initialize() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_update(prefs.getString(_tokenKey) != null);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Re-reads the auth state from disk and notifies listeners if it
|
||||
/// has changed. Call this from places that mutate the persisted
|
||||
/// token (e.g. after the login API call or after the user logs out).
|
||||
Future<void> refresh() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_update(prefs.getString(_tokenKey) != null);
|
||||
}
|
||||
|
||||
void _update(bool loggedIn) {
|
||||
if (_isLoggedIn == loggedIn) return;
|
||||
_isLoggedIn = loggedIn;
|
||||
// NOTE: we intentionally do NOT clear _intendedRoute here. The
|
||||
// router's redirect callback reads it on the next build frame via
|
||||
// [consumeIntendedRoute], which is responsible for clearing it
|
||||
// after it has been consumed.
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Called by the router's redirect callback when a protected route
|
||||
/// is hit by an unauthenticated user. Stores the route the user was
|
||||
/// trying to visit and returns the sign-in path the router should
|
||||
/// navigate to.
|
||||
String? requireAuth(String attemptedRoute) {
|
||||
if (_isLoggedIn) return null;
|
||||
// Avoid stacking redirects when the user is already on the
|
||||
// sign-in / sign-up screens.
|
||||
if (attemptedRoute.startsWith('/onboarding/')) return null;
|
||||
_intendedRoute = attemptedRoute;
|
||||
return '/onboarding/landing';
|
||||
}
|
||||
|
||||
/// Returns the previously captured intended route so the router can
|
||||
/// navigate the user there after a successful sign-in. The value is
|
||||
/// consumed (cleared) once read to avoid loops.
|
||||
String? consumeIntendedRoute() {
|
||||
if (!_isLoggedIn) return null;
|
||||
final route = _intendedRoute;
|
||||
_intendedRoute = null;
|
||||
return route;
|
||||
}
|
||||
}
|
||||
|
||||
/// Global singleton so widgets and the router can share the same
|
||||
/// instance without needing to plumb a provider through the app.
|
||||
final AuthState authState = AuthState();
|
||||
|
||||
/// Whether the auth state has finished its first read from disk.
|
||||
/// The router waits for this before evaluating auth-gated redirects
|
||||
/// so we don't briefly bounce users to the sign-in screen on cold
|
||||
/// start before their persisted token has been loaded.
|
||||
bool get isAuthInitialized => authState._initialized;
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
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 {
|
||||
Map<String, String> headers = await getHeaders();
|
||||
|
||||
Response response;
|
||||
response = await dio.get(baseUrl + url);
|
||||
response = await dio.get(baseUrl + url, options: Options(headers: headers));
|
||||
logger.i(response.data.toString());
|
||||
return response.data;
|
||||
}
|
||||
@@ -43,4 +57,29 @@ class Http {
|
||||
logger.i(response.data.toString());
|
||||
return response.data;
|
||||
}
|
||||
|
||||
Future<dynamic> put(String url, dynamic data) async {
|
||||
Map<String, String> headers = await getHeaders();
|
||||
|
||||
Response response;
|
||||
response = await dio.put(
|
||||
baseUrl + url,
|
||||
data: data,
|
||||
options: Options(headers: headers),
|
||||
);
|
||||
logger.i(response.data.toString());
|
||||
return response.data;
|
||||
}
|
||||
|
||||
Future<dynamic> delete(String url) async {
|
||||
Map<String, String> headers = await getHeaders();
|
||||
|
||||
Response response;
|
||||
response = await dio.delete(
|
||||
baseUrl + url,
|
||||
options: Options(headers: headers),
|
||||
);
|
||||
logger.i(response.data.toString());
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
|
||||
208
lib/main.dart
208
lib/main.dart
@@ -1,30 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qpay/navbar.dart';
|
||||
import 'package:qpay/screens/confirm/confirm_screen.dart';
|
||||
import 'package:qpay/screens/gateway/gateway_redirect.dart';
|
||||
import 'package:qpay/screens/gateway/gateway_screen.dart';
|
||||
import 'package:qpay/screens/gateway/gateway_web_screen.dart';
|
||||
import 'package:qpay/screens/home/home_screen.dart';
|
||||
import 'package:qpay/screens/history/history_screen.dart';
|
||||
import 'package:qpay/screens/integration/integration_screen.dart';
|
||||
import 'package:qpay/screens/onboarding/complete_screen.dart';
|
||||
import 'package:qpay/screens/onboarding/landing/landing_screen.dart';
|
||||
import 'package:qpay/screens/onboarding/login/login_screen.dart';
|
||||
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
|
||||
import 'package:qpay/screens/onboarding/password/password_screen.dart';
|
||||
import 'package:qpay/screens/onboarding/phone/phone_screen.dart';
|
||||
import 'package:qpay/screens/onboarding/register/register_screen.dart';
|
||||
import 'package:qpay/screens/onboarding/verify/verify_screen.dart';
|
||||
import 'package:qpay/screens/pay/pay_screen.dart';
|
||||
import 'package:qpay/screens/poll/poll_screen.dart';
|
||||
import 'package:qpay/screens/splash_screen.dart';
|
||||
import 'package:qpay/screens/receipt/receipt_screen.dart';
|
||||
import 'package:qpay/screens/recipient/recipients_screen.dart';
|
||||
import 'package:qpay/auth_state.dart';
|
||||
import 'package:qpay/routes.dart';
|
||||
import 'package:qpay/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:flutter_dotenv/flutter_dotenv.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 {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
usePathUrlStrategy();
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
|
||||
await dotenv.load(fileName: resolveEnvFile(appEnv));
|
||||
|
||||
// Read the persisted auth token before the first frame so the router
|
||||
// knows whether the user is already signed in on cold start. Without
|
||||
// this we would briefly treat logged-in users as guests and bounce
|
||||
// them to the sign-in screen until the first refresh kicked in.
|
||||
await authState.initialize();
|
||||
|
||||
runApp(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
@@ -110,150 +67,7 @@ class _MyAppState extends State<MyApp> {
|
||||
theme: AppTheme.lightTheme,
|
||||
themeMode: ThemeMode.light,
|
||||
debugShowCheckedModeBanner: false,
|
||||
routerConfig: GoRouter(
|
||||
initialLocation: '/',
|
||||
refreshListenable: splashState,
|
||||
errorBuilder: (context, state) => Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('Page not found', style: TextStyle(fontSize: 20)),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.go('/'),
|
||||
child: const Text('Go Home'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
redirect: (context, state) {
|
||||
// Before splash is complete: redirect everything to /splash,
|
||||
// but save the original intended destination.
|
||||
final guardResult = splashState.guard(state.uri.path);
|
||||
if (guardResult != null) return guardResult;
|
||||
|
||||
// Once splash is complete: if we're still on /splash, navigate
|
||||
// to the intended destination (or home as fallback).
|
||||
if (splashState.complete && state.uri.path == '/splash') {
|
||||
return splashState.intendedRoute ?? '/';
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
// Splash route sits outside the ShellRoute so it renders
|
||||
// without the bottom nav bar or side rail.
|
||||
GoRoute(
|
||||
path: '/splash',
|
||||
builder: (context, state) => const SplashScreen(),
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) {
|
||||
final location = GoRouterState.of(context).uri.toString();
|
||||
if (location.startsWith('/onboarding/')) {
|
||||
return child;
|
||||
}
|
||||
return ScaffoldWithNavBar(child: child);
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => const HomeScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/home',
|
||||
builder: (context, state) => const HomeScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/make-payment',
|
||||
builder: (context, state) => const PayScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/confirm',
|
||||
builder: (context, state) => const ConfirmScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/gateway',
|
||||
builder: (context, state) => const GatewayScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/gateway-web',
|
||||
builder: (context, state) => const GatewayWebScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/gateway-redirect',
|
||||
builder: (context, state) => const GatewayRedirectScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/poll/:id',
|
||||
builder: (context, state) => PollScreen(
|
||||
transactionId: state.pathParameters['id'],
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/poll',
|
||||
builder: (context, state) => const PollScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/receipt',
|
||||
builder: (context, state) => const ReceiptScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/receipt/:transactionId',
|
||||
builder: (context, state) => ReceiptScreen(
|
||||
transactionId: state.pathParameters['transactionId'],
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/integration',
|
||||
builder: (context, state) => const IntegrationScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/recipients',
|
||||
builder: (context, state) => const RecipientsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/history',
|
||||
builder: (context, state) => const HistoryScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/profile',
|
||||
builder: (context, state) => const ProfileScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding/landing',
|
||||
builder: (context, state) => const LandingScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding/register',
|
||||
builder: (context, state) => const RegisterScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding/phone',
|
||||
builder: (context, state) => const PhoneScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding/verify',
|
||||
builder: (context, state) => const VerifyScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding/password',
|
||||
builder: (context, state) => const PasswordScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding/login',
|
||||
builder: (context, state) => const LoginScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding/complete',
|
||||
builder: (context, state) => const CompleteScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
routerConfig: buildRouter(),
|
||||
);
|
||||
}
|
||||
}
|
||||
211
lib/navbar.dart
211
lib/navbar.dart
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'models/responsive_policy.dart';
|
||||
|
||||
@@ -43,22 +44,43 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
||||
body: SafeArea(
|
||||
child: Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
extended: true,
|
||||
leading: Image(
|
||||
image: AssetImage('assets/velocity.png'),
|
||||
width: 150
|
||||
SizedBox(
|
||||
width: 256,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: NavigationRail(
|
||||
extended: true,
|
||||
leading: 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(
|
||||
thickness: 1,
|
||||
@@ -121,6 +143,11 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
||||
"selectedIcon": Icon(Icons.home),
|
||||
"label": 'Home',
|
||||
},
|
||||
{
|
||||
"icon": Icon(Icons.group_outlined),
|
||||
"selectedIcon": Icon(Icons.group),
|
||||
"label": 'Groups',
|
||||
},
|
||||
{
|
||||
"icon": Icon(Icons.history_outlined),
|
||||
"selectedIcon": Icon(Icons.history),
|
||||
@@ -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) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
context.go('/home');
|
||||
break;
|
||||
// case 1:
|
||||
// context.go('/recipients');
|
||||
// break;
|
||||
case 1:
|
||||
context.go('/groups');
|
||||
break;
|
||||
case 2:
|
||||
context.go('/history');
|
||||
break;
|
||||
// case 2:
|
||||
// context.go('/profile');
|
||||
// break;
|
||||
}
|
||||
}
|
||||
|
||||
int _calculateSelectedIndex(BuildContext context) {
|
||||
final String location = GoRouterState.of(context).uri.path;
|
||||
// if (location.startsWith('/recipients')) return 1;
|
||||
if (location.startsWith('/history')) return 1;
|
||||
// if (location.startsWith('/profile')) return 2;
|
||||
if (location.startsWith('/groups')) return 1;
|
||||
if (location.startsWith('/history')) return 2;
|
||||
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
282
lib/routes.dart
Normal 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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
101
lib/screens/accounts/account_provider.dart
Normal file
101
lib/screens/accounts/account_provider.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
67
lib/screens/accounts/models/account_model.dart
Normal file
67
lib/screens/accounts/models/account_model.dart
Normal file
@@ -0,0 +1,67 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'account_model.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class CurrencyModel {
|
||||
final String id;
|
||||
final String? createdAt;
|
||||
final String name;
|
||||
final String symbol;
|
||||
final String code;
|
||||
final double rate;
|
||||
final bool defaultCurrency;
|
||||
|
||||
CurrencyModel({
|
||||
required this.id,
|
||||
this.createdAt,
|
||||
required this.name,
|
||||
required this.symbol,
|
||||
required this.code,
|
||||
required this.rate,
|
||||
required this.defaultCurrency,
|
||||
});
|
||||
|
||||
factory CurrencyModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$CurrencyModelFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$CurrencyModelToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class AccountModel {
|
||||
final String name;
|
||||
final String description;
|
||||
final String accountNumber;
|
||||
final String alternateAccountNumber;
|
||||
final String type;
|
||||
final String category;
|
||||
final String ledger;
|
||||
final double balance;
|
||||
final String partyType;
|
||||
final String party;
|
||||
final CurrencyModel currency;
|
||||
final String customerStringId;
|
||||
final String companyStringId;
|
||||
|
||||
AccountModel({
|
||||
required this.name,
|
||||
required this.description,
|
||||
required this.accountNumber,
|
||||
required this.alternateAccountNumber,
|
||||
required this.type,
|
||||
required this.category,
|
||||
required this.ledger,
|
||||
required this.balance,
|
||||
required this.partyType,
|
||||
required this.party,
|
||||
required this.currency,
|
||||
required this.customerStringId,
|
||||
required this.companyStringId,
|
||||
});
|
||||
|
||||
factory AccountModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$AccountModelFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$AccountModelToJson(this);
|
||||
}
|
||||
62
lib/screens/accounts/models/account_model.g.dart
Normal file
62
lib/screens/accounts/models/account_model.g.dart
Normal file
@@ -0,0 +1,62 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'account_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
CurrencyModel _$CurrencyModelFromJson(Map<String, dynamic> json) =>
|
||||
CurrencyModel(
|
||||
id: json['id'] as String,
|
||||
createdAt: json['createdAt'] as String?,
|
||||
name: json['name'] as String,
|
||||
symbol: json['symbol'] as String,
|
||||
code: json['code'] as String,
|
||||
rate: (json['rate'] as num).toDouble(),
|
||||
defaultCurrency: json['defaultCurrency'] as bool,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$CurrencyModelToJson(CurrencyModel instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'createdAt': instance.createdAt,
|
||||
'name': instance.name,
|
||||
'symbol': instance.symbol,
|
||||
'code': instance.code,
|
||||
'rate': instance.rate,
|
||||
'defaultCurrency': instance.defaultCurrency,
|
||||
};
|
||||
|
||||
AccountModel _$AccountModelFromJson(Map<String, dynamic> json) => AccountModel(
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String,
|
||||
accountNumber: json['accountNumber'] as String,
|
||||
alternateAccountNumber: json['alternateAccountNumber'] as String,
|
||||
type: json['type'] as String,
|
||||
category: json['category'] as String,
|
||||
ledger: json['ledger'] as String,
|
||||
balance: (json['balance'] as num).toDouble(),
|
||||
partyType: json['partyType'] as String,
|
||||
party: json['party'] as String,
|
||||
currency: CurrencyModel.fromJson(json['currency'] as Map<String, dynamic>),
|
||||
customerStringId: json['customerStringId'] as String,
|
||||
companyStringId: json['companyStringId'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$AccountModelToJson(AccountModel instance) =>
|
||||
<String, dynamic>{
|
||||
'name': instance.name,
|
||||
'description': instance.description,
|
||||
'accountNumber': instance.accountNumber,
|
||||
'alternateAccountNumber': instance.alternateAccountNumber,
|
||||
'type': instance.type,
|
||||
'category': instance.category,
|
||||
'ledger': instance.ledger,
|
||||
'balance': instance.balance,
|
||||
'partyType': instance.partyType,
|
||||
'party': instance.party,
|
||||
'currency': instance.currency,
|
||||
'customerStringId': instance.customerStringId,
|
||||
'companyStringId': instance.companyStringId,
|
||||
};
|
||||
63
lib/screens/accounts/models/statement_model.dart
Normal file
63
lib/screens/accounts/models/statement_model.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
part 'statement_model.g.dart';
|
||||
|
||||
@JsonSerializable()
|
||||
class TransactionModel {
|
||||
final String name;
|
||||
final String narration;
|
||||
final String party;
|
||||
final String company;
|
||||
final String postingDate;
|
||||
final double amount;
|
||||
final String account;
|
||||
final String accountName;
|
||||
final String currency;
|
||||
final String type;
|
||||
final double balance;
|
||||
|
||||
TransactionModel({
|
||||
required this.name,
|
||||
required this.narration,
|
||||
required this.party,
|
||||
required this.company,
|
||||
required this.postingDate,
|
||||
required this.amount,
|
||||
required this.account,
|
||||
required this.accountName,
|
||||
required this.currency,
|
||||
required this.type,
|
||||
required this.balance,
|
||||
});
|
||||
|
||||
factory TransactionModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$TransactionModelFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$TransactionModelToJson(this);
|
||||
}
|
||||
|
||||
@JsonSerializable()
|
||||
class StatementModel {
|
||||
final int startBalance;
|
||||
final double endBalance;
|
||||
final List<TransactionModel> transactions;
|
||||
final String accountName;
|
||||
final String accountNumber;
|
||||
final String startDate;
|
||||
final String endDate;
|
||||
|
||||
StatementModel({
|
||||
required this.startBalance,
|
||||
required this.endBalance,
|
||||
required this.transactions,
|
||||
required this.accountName,
|
||||
required this.accountNumber,
|
||||
required this.startDate,
|
||||
required this.endDate,
|
||||
});
|
||||
|
||||
factory StatementModel.fromJson(Map<String, dynamic> json) =>
|
||||
_$StatementModelFromJson(json);
|
||||
|
||||
Map<String, dynamic> toJson() => _$StatementModelToJson(this);
|
||||
}
|
||||
61
lib/screens/accounts/models/statement_model.g.dart
Normal file
61
lib/screens/accounts/models/statement_model.g.dart
Normal file
@@ -0,0 +1,61 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'statement_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
TransactionModel _$TransactionModelFromJson(Map<String, dynamic> json) =>
|
||||
TransactionModel(
|
||||
name: json['name'] as String,
|
||||
narration: json['narration'] as String,
|
||||
party: json['party'] as String,
|
||||
company: json['company'] as String,
|
||||
postingDate: json['postingDate'] as String,
|
||||
amount: (json['amount'] as num).toDouble(),
|
||||
account: json['account'] as String,
|
||||
accountName: json['accountName'] as String,
|
||||
currency: json['currency'] as String,
|
||||
type: json['type'] as String,
|
||||
balance: (json['balance'] as num).toDouble(),
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$TransactionModelToJson(TransactionModel instance) =>
|
||||
<String, dynamic>{
|
||||
'name': instance.name,
|
||||
'narration': instance.narration,
|
||||
'party': instance.party,
|
||||
'company': instance.company,
|
||||
'postingDate': instance.postingDate,
|
||||
'amount': instance.amount,
|
||||
'account': instance.account,
|
||||
'accountName': instance.accountName,
|
||||
'currency': instance.currency,
|
||||
'type': instance.type,
|
||||
'balance': instance.balance,
|
||||
};
|
||||
|
||||
StatementModel _$StatementModelFromJson(Map<String, dynamic> json) =>
|
||||
StatementModel(
|
||||
startBalance: (json['startBalance'] as num).toInt(),
|
||||
endBalance: (json['endBalance'] as num).toDouble(),
|
||||
transactions: (json['transactions'] as List<dynamic>)
|
||||
.map((e) => TransactionModel.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
accountName: json['accountName'] as String,
|
||||
accountNumber: json['accountNumber'] as String,
|
||||
startDate: json['startDate'] as String,
|
||||
endDate: json['endDate'] as String,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$StatementModelToJson(StatementModel instance) =>
|
||||
<String, dynamic>{
|
||||
'startBalance': instance.startBalance,
|
||||
'endBalance': instance.endBalance,
|
||||
'transactions': instance.transactions,
|
||||
'accountName': instance.accountName,
|
||||
'accountNumber': instance.accountNumber,
|
||||
'startDate': instance.startDate,
|
||||
'endDate': instance.endDate,
|
||||
};
|
||||
390
lib/screens/accounts/widgets/account_balance_widget.dart
Normal file
390
lib/screens/accounts/widgets/account_balance_widget.dart
Normal 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)}';
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qpay/models/api_response.dart';
|
||||
import 'package:qpay/screens/transaction_controller.dart';
|
||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||
|
||||
import '../../http/http.dart';
|
||||
|
||||
@@ -43,13 +45,7 @@ class ConfirmController extends ChangeNotifier {
|
||||
|
||||
void _showErrorSnackBar(String? message) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message.toString()),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Colors.deepOrange,
|
||||
),
|
||||
);
|
||||
AppSnackBar.showError(context, message.toString());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,19 +55,21 @@ class ConfirmController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
// todo: find out if we still need to update these fields
|
||||
transactionController
|
||||
transactionController.model.formData = transactionController
|
||||
.model
|
||||
.formData = transactionController.model.formData.copyWith(
|
||||
type: "REQUEST",
|
||||
trace: transactionController.model.confirmationData['trace'],
|
||||
id: transactionController.model.confirmationData['id'],
|
||||
authType: transactionController.model.selectedPaymentProcessor.authType,
|
||||
);
|
||||
.formData
|
||||
.copyWith(
|
||||
type: "REQUEST",
|
||||
trace: transactionController.model.confirmationData['trace'],
|
||||
id: transactionController.model.confirmationData['id'],
|
||||
authType:
|
||||
transactionController.model.selectedPaymentProcessor.authType,
|
||||
);
|
||||
|
||||
dynamic workflowResponse = await http.get(
|
||||
'/public/transaction/request/'
|
||||
'${transactionController.model.confirmationData['id']}/'
|
||||
'${transactionController.model.selectedPaymentProcessor.authType}'
|
||||
'${transactionController.model.confirmationData['id']}/'
|
||||
'${transactionController.model.selectedPaymentProcessor.authType}',
|
||||
);
|
||||
logger.i(workflowResponse.toString());
|
||||
|
||||
@@ -87,7 +85,9 @@ class ConfirmController extends ChangeNotifier {
|
||||
} else {
|
||||
model.status = response['status'];
|
||||
model.errorMessage = response['errorMessage'];
|
||||
return ApiResponse.failure(response['errorMessage'] ?? "Transaction failed");
|
||||
return ApiResponse.failure(
|
||||
response['errorMessage'] ?? "Transaction failed",
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.e(e);
|
||||
@@ -130,6 +130,53 @@ class ConfirmController extends ChangeNotifier {
|
||||
return ApiResponse.failure("Polling cancelled");
|
||||
}
|
||||
|
||||
Future<ApiResponse<Map<String, dynamic>>> updateVelocityTransactionOtp(
|
||||
String id,
|
||||
Map<String, dynamic> transaction,
|
||||
) async {
|
||||
try {
|
||||
model.isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
dynamic response = await http.put(
|
||||
'/public/transaction/$id/velocity-otp',
|
||||
transaction,
|
||||
);
|
||||
|
||||
model.isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
if (response['status'] == 'SUCCESS') {
|
||||
return ApiResponse.success(Map<String, dynamic>.from(response));
|
||||
} else {
|
||||
model.errorMessage =
|
||||
response['errorMessage'] ??
|
||||
"Problem updating transaction. Please try again or contact support";
|
||||
return ApiResponse.failure(model.errorMessage!);
|
||||
}
|
||||
} on DioException catch (e, s) {
|
||||
if (kDebugMode) {
|
||||
logger.e(e);
|
||||
logger.e(s);
|
||||
}
|
||||
model.isLoading = false;
|
||||
notifyListeners();
|
||||
return ApiResponse.failure(
|
||||
"Network error. Please try again or contact support",
|
||||
);
|
||||
} catch (e, s) {
|
||||
if (kDebugMode) {
|
||||
logger.e(e);
|
||||
logger.e(s);
|
||||
}
|
||||
model.isLoading = false;
|
||||
notifyListeners();
|
||||
return ApiResponse.failure(
|
||||
"Problem updating that transaction. Please try again or contact support",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
// TODO: implement dispose
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/confirm/confirm_controller.dart';
|
||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
|
||||
class ConfirmScreen extends StatefulWidget {
|
||||
@@ -65,17 +66,11 @@ class _ConfirmScreenState extends State<ConfirmScreen>
|
||||
final response = await controller.doTransaction();
|
||||
if (!response.isSuccess) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
controller.model.errorMessage ??
|
||||
response.error ??
|
||||
"Transaction failed",
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
width: 600,
|
||||
backgroundColor: Colors.deepOrange,
|
||||
),
|
||||
AppSnackBar.showError(
|
||||
context,
|
||||
controller.model.errorMessage ??
|
||||
response.error ??
|
||||
"Transaction failed",
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -83,6 +78,41 @@ class _ConfirmScreenState extends State<ConfirmScreen>
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Check if the selected payment processor is a wallet (e.g. Velocity)
|
||||
if (controller
|
||||
.transactionController
|
||||
.model
|
||||
.selectedPaymentProcessor
|
||||
.label ==
|
||||
'WALLET') {
|
||||
final otp = await _showOtpBottomSheet();
|
||||
if (otp == null) return; // User cancelled or dismissed the bottom sheet
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final transactionId =
|
||||
controller.transactionController.model.confirmationData['id'];
|
||||
final otpResponse = await controller.updateVelocityTransactionOtp(
|
||||
transactionId,
|
||||
{'verificationCode': otp},
|
||||
);
|
||||
|
||||
if (!otpResponse.isSuccess) {
|
||||
if (context.mounted) {
|
||||
AppSnackBar.showError(
|
||||
context,
|
||||
controller.model.errorMessage ??
|
||||
otpResponse.error ??
|
||||
"OTP verification failed",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
}
|
||||
|
||||
// Proceed with normal flow after OTP step (or skip if not WALLET)
|
||||
if (controller
|
||||
.transactionController
|
||||
.model
|
||||
@@ -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
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:qpay/http/http.dart';
|
||||
import 'package:qpay/models/api_response.dart';
|
||||
import 'package:qpay/screens/transaction_controller.dart';
|
||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class GatewayModel {
|
||||
@@ -30,13 +31,7 @@ class GatewayController extends ChangeNotifier {
|
||||
|
||||
void _showErrorSnackBar(String? message) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message.toString()),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Colors.deepOrange,
|
||||
),
|
||||
);
|
||||
AppSnackBar.showError(context, message.toString());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -54,8 +49,9 @@ class GatewayController extends ChangeNotifier {
|
||||
Future<ApiResponse<Map<String, dynamic>>> poll(String uid) async {
|
||||
startLoading();
|
||||
try {
|
||||
dynamic workflowResponse =
|
||||
await http.get('/public/transaction/poll/$uid');
|
||||
dynamic workflowResponse = await http.get(
|
||||
'/public/transaction/poll/$uid',
|
||||
);
|
||||
|
||||
logger.i(workflowResponse.toString());
|
||||
|
||||
@@ -67,6 +63,10 @@ class GatewayController extends ChangeNotifier {
|
||||
finishLoading();
|
||||
if (model.status == 'SUCCESS') {
|
||||
transactionController.updateReceiptData(response);
|
||||
} else {
|
||||
model.status = 'FAILED';
|
||||
model.errorMessage = response['errorMessage'];
|
||||
return ApiResponse.failure(response['errorMessage']);
|
||||
}
|
||||
notifyListeners();
|
||||
return ApiResponse.success(Map<String, dynamic>.from(response));
|
||||
@@ -84,7 +84,7 @@ class GatewayController extends ChangeNotifier {
|
||||
|
||||
Future<ApiResponse<Map<String, dynamic>>> pollTransaction(String uid) async {
|
||||
// poll up to 30 times (~5 minutes at 10-second intervals)
|
||||
int maxAttempts = 30;
|
||||
int maxAttempts = 15;
|
||||
int attempt = 0;
|
||||
|
||||
while (!model.isCancelled && attempt < maxAttempts) {
|
||||
@@ -108,6 +108,9 @@ class GatewayController extends ChangeNotifier {
|
||||
// Otherwise (e.g. PENDING), continue polling
|
||||
} else {
|
||||
// Network error — show failure UI and stop
|
||||
model.isCancelled = true;
|
||||
model.status = 'FAILED';
|
||||
model.errorMessage = result.error;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -123,7 +126,8 @@ class GatewayController extends ChangeNotifier {
|
||||
|
||||
// Alternative method using Timer instead of while loop
|
||||
Future<ApiResponse<Map<String, dynamic>>> pollTransactionWithTimer(
|
||||
String uid) async {
|
||||
String uid,
|
||||
) async {
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 5), (timer) async {
|
||||
if (model.isCancelled) {
|
||||
timer.cancel();
|
||||
@@ -150,7 +154,8 @@ class GatewayController extends ChangeNotifier {
|
||||
// This returns immediately; the caller should use the timer callback approach.
|
||||
// Consider refactoring this to use poll(String uid) directly for a consistent API.
|
||||
return ApiResponse.failure(
|
||||
"Polling via timer started; use a different flow for the result");
|
||||
"Polling via timer started; use a different flow for the result",
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qpay/screens/gateway/gateway_controller.dart';
|
||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
class GatewayScreen extends StatefulWidget {
|
||||
@@ -69,15 +70,11 @@ class _GatewayScreenState extends State<GatewayScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
"Payment was successful, please wait while we process your order.",
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Colors.black87,
|
||||
duration: const Duration(seconds: 10),
|
||||
),
|
||||
AppSnackBar.show(
|
||||
context,
|
||||
'Payment was successful, please wait while we process your order.',
|
||||
backgroundColor: Colors.black87,
|
||||
duration: const Duration(seconds: 10),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
|
||||
439
lib/screens/groups/batch_create_screen.dart
Normal file
439
lib/screens/groups/batch_create_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1697
lib/screens/groups/batch_detail_screen.dart
Normal file
1697
lib/screens/groups/batch_detail_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
172
lib/screens/groups/batch_item_screen.dart
Normal file
172
lib/screens/groups/batch_item_screen.dart
Normal 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))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
502
lib/screens/groups/controllers/batch_controller.dart
Normal file
502
lib/screens/groups/controllers/batch_controller.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
255
lib/screens/groups/controllers/group_controller.dart
Normal file
255
lib/screens/groups/controllers/group_controller.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
220
lib/screens/groups/group_create_screen.dart
Normal file
220
lib/screens/groups/group_create_screen.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
1299
lib/screens/groups/group_detail_screen.dart
Normal file
1299
lib/screens/groups/group_detail_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
433
lib/screens/groups/groups_screen.dart
Normal file
433
lib/screens/groups/groups_screen.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
143
lib/screens/groups/models/group_batch_model.dart
Normal file
143
lib/screens/groups/models/group_batch_model.dart
Normal 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);
|
||||
}
|
||||
128
lib/screens/groups/models/group_batch_model.g.dart
Normal file
128
lib/screens/groups/models/group_batch_model.g.dart
Normal 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,
|
||||
};
|
||||
72
lib/screens/groups/models/recipient_group_model.dart
Normal file
72
lib/screens/groups/models/recipient_group_model.dart
Normal 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);
|
||||
}
|
||||
67
lib/screens/groups/models/recipient_group_model.g.dart
Normal file
67
lib/screens/groups/models/recipient_group_model.g.dart
Normal 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(),
|
||||
};
|
||||
@@ -8,54 +8,37 @@ class HistoryModel {
|
||||
|
||||
List<Map<String, dynamic>> transactions = [];
|
||||
bool isLoading = false;
|
||||
bool isLoadingMore = false;
|
||||
bool isActing = false;
|
||||
String? errorMessage;
|
||||
|
||||
int currentPage = 0;
|
||||
int totalPages = 0;
|
||||
|
||||
String searchQuery = '';
|
||||
String? statusFilter;
|
||||
String? typeFilter;
|
||||
|
||||
bool selectMode = false;
|
||||
Set<String> selectedTransactionIds = {};
|
||||
}
|
||||
|
||||
class HistoryController extends ChangeNotifier {
|
||||
final HistoryModel model = HistoryModel();
|
||||
final Http http = Http();
|
||||
BuildContext context;
|
||||
bool _disposed = false;
|
||||
|
||||
HistoryController(this.context);
|
||||
|
||||
Future<ApiResponse<List<Map<String, dynamic>>>> getTransactions(Map<String, String> params) async {
|
||||
model.isLoading = true;
|
||||
if (params['page'] == '0') {
|
||||
model.transactions = getFakeTransactions();
|
||||
}
|
||||
notifyListeners();
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, dynamic> response = await http.get(
|
||||
'/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?",
|
||||
);
|
||||
}
|
||||
void _safeNotify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
String buildQueryParameters(Map<String, String> params) {
|
||||
@@ -70,6 +53,152 @@ class HistoryController extends ChangeNotifier {
|
||||
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() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:qpay/screens/receipt/receipt_controller.dart';
|
||||
import 'package:qpay/screens/transaction_controller.dart';
|
||||
import 'package:shared_preferences/shared_preferences.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/screens/transactions/transaction_model.dart' as txn;
|
||||
|
||||
@@ -20,39 +21,61 @@ class _HistoryScreenState extends State<HistoryScreen>
|
||||
with TickerProviderStateMixin {
|
||||
late HistoryController controller;
|
||||
late SharedPreferences prefs;
|
||||
late AnimationController _controller;
|
||||
late TransactionController transactionController;
|
||||
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final Map<String, String> _queryParams = {};
|
||||
final int _pageSize = 8;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
late final AnimationController _entryController;
|
||||
late final Animation<double> _entryFade;
|
||||
late final Animation<Offset> _entrySlide;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = HistoryController(context);
|
||||
setupData();
|
||||
|
||||
transactionController = Provider.of<TransactionController>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 3000),
|
||||
// Short, subtle entry animation (replaces the old 3-second stagger)
|
||||
_entryController = AnimationController(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
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();
|
||||
final userId = prefs.getString("userId") ?? '';
|
||||
await controller.searchTransactions(userId);
|
||||
}
|
||||
|
||||
await controller.getTransactions({
|
||||
'userId': prefs.getString("userId")!,
|
||||
'page': '0',
|
||||
'size': _pageSize.toString(),
|
||||
'sort': 'createdAt,desc',
|
||||
});
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 200) {
|
||||
final userId = prefs.getString("userId") ?? '';
|
||||
if (userId.isNotEmpty) {
|
||||
controller.loadNextPage(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the repeat action for a transaction.
|
||||
@@ -60,10 +83,178 @@ class _HistoryScreenState extends State<HistoryScreen>
|
||||
final receiptController = ReceiptController(context);
|
||||
final transaction = txn.TransactionModel.fromJson(item);
|
||||
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
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_scrollController.dispose();
|
||||
_entryController.dispose();
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -71,96 +262,309 @@ class _HistoryScreenState extends State<HistoryScreen>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
title: Text(
|
||||
'History',
|
||||
style: TextStyle(fontSize: 20, color: Colors.black),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SizedBox(
|
||||
width: double.parse(
|
||||
constraints.maxWidth > ResponsivePolicy.md
|
||||
? ResponsivePolicy.md.toString()
|
||||
: constraints.maxWidth.toString(),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildSearchField(controller),
|
||||
SizedBox(height: 10),
|
||||
if (controller.model.transactions.isEmpty)
|
||||
Column(
|
||||
appBar: AppBar(title: const Text('History'), centerTitle: true),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: FadeTransition(
|
||||
opacity: _entryFade,
|
||||
child: SlideTransition(
|
||||
position: _entrySlide,
|
||||
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: [
|
||||
SizedBox(height: 30),
|
||||
Text(
|
||||
'No transactions yet. Make a payment to see your recent activity.',
|
||||
style: TextStyle(fontSize: 16),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
_buildSearchRow(),
|
||||
const SizedBox(height: 12),
|
||||
_buildContent(),
|
||||
],
|
||||
),
|
||||
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) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
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")!;
|
||||
Widget _buildTransactionItem(Map<String, dynamic> e, BuildContext context) {
|
||||
final id = e['id'] as String? ?? '';
|
||||
final isSelected = controller.model.selectedTransactionIds.contains(id);
|
||||
final inSelectMode = controller.model.selectMode;
|
||||
|
||||
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,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search by name, account, email, phone',
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
if (inSelectMode)
|
||||
Positioned.fill(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
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(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.2),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(
|
||||
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) {
|
||||
int index = controller.model.transactions.indexOf(e);
|
||||
Animation<Offset> animation =
|
||||
Tween<Offset>(begin: Offset(0, -0.25), end: Offset.zero).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Interval(0.175 * index, 1.0, curve: Curves.easeIn),
|
||||
Widget _buildSelectionBottomBar() {
|
||||
final selectedCount = controller.model.selectedTransactionIds.length;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.08),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
);
|
||||
|
||||
return SlideTransition(
|
||||
position: animation,
|
||||
child: TransactionItemWidget(
|
||||
transaction: e,
|
||||
enabled: controller.model.isLoading,
|
||||
onRepeat: () async {
|
||||
await _repeatTransaction(e);
|
||||
if (context.mounted) {
|
||||
context.push('/make-payment');
|
||||
}
|
||||
},
|
||||
],
|
||||
),
|
||||
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: () => 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ abstract class Category with _$Category {
|
||||
@freezed
|
||||
abstract class BillProvider with _$BillProvider {
|
||||
const factory BillProvider({
|
||||
required String id,
|
||||
required String clientId,
|
||||
required String name,
|
||||
required String description,
|
||||
@@ -100,7 +101,7 @@ class HomeController extends ChangeNotifier {
|
||||
return ApiResponse.success(model.accounts);
|
||||
}
|
||||
|
||||
Future<ApiResponse<List<BillProvider>>> getProviders() async {
|
||||
Future<ApiResponse<List<BillProvider>>> getProviders(String currency) async {
|
||||
try {
|
||||
model.filterableProviders = getFakeProviders();
|
||||
model.transactions = getFakeTransactions();
|
||||
@@ -108,7 +109,9 @@ class HomeController extends ChangeNotifier {
|
||||
model.isLoading = true;
|
||||
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
|
||||
.map((e) => BillProvider.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
@@ -152,7 +155,7 @@ class HomeController extends ChangeNotifier {
|
||||
|
||||
try {
|
||||
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);
|
||||
@@ -258,6 +261,7 @@ class HomeController extends ChangeNotifier {
|
||||
List<BillProvider> getFakeProviders() {
|
||||
return [
|
||||
BillProvider(
|
||||
id: '1',
|
||||
clientId: 'econet_airtime',
|
||||
name: 'Econet Airtime',
|
||||
description: 'Econet Airtime',
|
||||
|
||||
@@ -287,7 +287,7 @@ as String,
|
||||
/// @nodoc
|
||||
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
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -300,16 +300,16 @@ $BillProviderCopyWith<BillProvider> get copyWith => _$BillProviderCopyWithImpl<B
|
||||
|
||||
@override
|
||||
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)
|
||||
@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
|
||||
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;
|
||||
@useResult
|
||||
$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
|
||||
/// 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(
|
||||
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,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
|
||||
@@ -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) {
|
||||
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();
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
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');
|
||||
|
||||
}
|
||||
@@ -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) {
|
||||
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;
|
||||
|
||||
}
|
||||
@@ -495,9 +496,10 @@ return $default(_that.clientId,_that.name,_that.description,_that.requiresAccoun
|
||||
@JsonSerializable()
|
||||
|
||||
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);
|
||||
|
||||
@override final String id;
|
||||
@override final String clientId;
|
||||
@override final String name;
|
||||
@override final String description;
|
||||
@@ -527,16 +529,16 @@ Map<String, dynamic> toJson() {
|
||||
|
||||
@override
|
||||
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)
|
||||
@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
|
||||
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;
|
||||
@override @useResult
|
||||
$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
|
||||
/// 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(
|
||||
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,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
|
||||
|
||||
@@ -22,6 +22,7 @@ Map<String, dynamic> _$CategoryToJson(_Category instance) => <String, dynamic>{
|
||||
|
||||
_BillProvider _$BillProviderFromJson(Map<String, dynamic> json) =>
|
||||
_BillProvider(
|
||||
id: json['id'] as String,
|
||||
clientId: json['clientId'] as String,
|
||||
name: json['name'] as String,
|
||||
description: json['description'] as String,
|
||||
@@ -41,6 +42,7 @@ _BillProvider _$BillProviderFromJson(Map<String, dynamic> json) =>
|
||||
|
||||
Map<String, dynamic> _$BillProviderToJson(_BillProvider instance) =>
|
||||
<String, dynamic>{
|
||||
'id': instance.id,
|
||||
'clientId': instance.clientId,
|
||||
'name': instance.name,
|
||||
'description': instance.description,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qpay/auth_state.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/receipt/receipt_controller.dart';
|
||||
import 'package:qpay/screens/transaction_controller.dart';
|
||||
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:skeletonizer/skeletonizer.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);
|
||||
|
||||
bool _isLoggedIn = false;
|
||||
bool _isZWGSelected = false;
|
||||
String initials = '';
|
||||
|
||||
late AnimationController _providersAnimController;
|
||||
@@ -103,7 +107,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
homeController = HomeController(context);
|
||||
await homeController.getAccounts();
|
||||
await homeController.getCategories();
|
||||
await homeController.getProviders();
|
||||
await homeController.getProviders(_isZWGSelected ? "ZWG" : "USD");
|
||||
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
|
||||
@@ -124,7 +128,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
Future<void> _onRefresh() async {
|
||||
await homeController.getAccounts();
|
||||
await homeController.getCategories();
|
||||
await homeController.getProviders();
|
||||
await homeController.getProviders(_isZWGSelected ? "ZWG" : "USD");
|
||||
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
if (prefs.getString("userId") != null) {
|
||||
@@ -193,6 +197,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isMobile = constraints.maxWidth < ResponsivePolicy.md;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDark
|
||||
? const Color(0xFF0D0D0D)
|
||||
@@ -205,69 +211,21 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
child: CustomScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
pinned: false,
|
||||
floating: true,
|
||||
stretch: true,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
backgroundColor: isDark
|
||||
? const Color(0xFF0D0D0D)
|
||||
: const Color(0xFFF8F9FA),
|
||||
title: constraints.maxWidth < ResponsivePolicy.md
|
||||
? Image(
|
||||
image: const AssetImage('assets/velocity.png'),
|
||||
width: 130,
|
||||
)
|
||||
: null,
|
||||
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,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (isMobile)
|
||||
SliverAppBar(
|
||||
pinned: false,
|
||||
floating: true,
|
||||
stretch: true,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
backgroundColor: isDark
|
||||
? const Color(0xFF0D0D0D)
|
||||
: const Color(0xFFF8F9FA),
|
||||
title: Image(
|
||||
image: const AssetImage('assets/velocity.png'),
|
||||
width: 130,
|
||||
),
|
||||
actions: _buildAppBarActions(theme, isDark),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
@@ -281,6 +239,10 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isMobile) ...[
|
||||
_buildWebHeader(theme, isDark),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
FadeTransition(
|
||||
opacity: _providersFadeAnimation,
|
||||
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
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
@@ -338,19 +365,129 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!_isLoggedIn) _buildLoginPrompt(theme, isDark),
|
||||
if (_isLoggedIn) const AccountBalanceWidget(),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildSectionLabel(theme, isDark, Icons.grid_view_rounded, "Pay"),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.refresh_rounded,
|
||||
color: isDark ? Colors.white54 : Colors.black54,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: _onRefresh,
|
||||
tooltip: 'Refresh',
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark ? const Color(0xFF1E1E1E) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.12)
|
||||
: Colors.black.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'USD',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: !_isZWGSelected
|
||||
? theme.colorScheme.primary
|
||||
: isDark
|
||||
? Colors.white54
|
||||
: Colors.black45,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_isZWGSelected = !_isZWGSelected;
|
||||
});
|
||||
if (_isZWGSelected) {
|
||||
AppSnackBar.show(
|
||||
context,
|
||||
'ZWG currency not supported yet',
|
||||
customContent: const Center(
|
||||
child: Text('ZWG currency not supported yet'),
|
||||
),
|
||||
backgroundColor: theme.colorScheme.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
);
|
||||
}
|
||||
|
||||
Future.delayed(
|
||||
const Duration(milliseconds: 1000),
|
||||
() {
|
||||
setState(() {
|
||||
_isZWGSelected = !_isZWGSelected;
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 32,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: _isZWGSelected
|
||||
? theme.colorScheme.primary
|
||||
: Colors.grey.shade300,
|
||||
),
|
||||
child: AnimatedAlign(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
alignment: _isZWGSelected
|
||||
? Alignment.centerRight
|
||||
: Alignment.centerLeft,
|
||||
child: Container(
|
||||
width: 14,
|
||||
height: 14,
|
||||
margin: const EdgeInsets.all(2),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'ZWG',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _isZWGSelected
|
||||
? theme.colorScheme.primary
|
||||
: isDark
|
||||
? Colors.white54
|
||||
: Colors.black45,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
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,
|
||||
children: [
|
||||
Text(
|
||||
'Register or Login for more',
|
||||
'Register or Login for more features',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
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(() {
|
||||
_isLoggedIn = false;
|
||||
});
|
||||
authState.refresh();
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/onboarding/login/login_controller.dart';
|
||||
import 'package:qpay/screens/onboarding/widgets/onboarding_layout.dart';
|
||||
|
||||
class CompleteScreen extends StatefulWidget {
|
||||
const CompleteScreen({super.key});
|
||||
@@ -13,65 +12,77 @@ class CompleteScreen extends StatefulWidget {
|
||||
class _CompleteScreenState extends State<CompleteScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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 Complete',
|
||||
style: TextStyle(
|
||||
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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return OnboardingLayout(
|
||||
// No back button - the user shouldn't be able to go back to the
|
||||
// verification step after their account is fully set up.
|
||||
showBackButton: false,
|
||||
child: OnboardingCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Success badge - green to differentiate from the brand primary.
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(alpha: 0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Colors.green,
|
||||
size: 56,
|
||||
),
|
||||
),
|
||||
),
|
||||
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');
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/onboarding/login/login_controller.dart';
|
||||
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
|
||||
|
||||
class LandingScreen extends StatefulWidget {
|
||||
const LandingScreen({super.key});
|
||||
@@ -11,90 +12,647 @@ class LandingScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
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
|
||||
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(
|
||||
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(
|
||||
'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)),
|
||||
),
|
||||
],
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
// Use a Stack so the gradient background is guaranteed to cover the
|
||||
// full viewport (including the area under the SafeArea) regardless
|
||||
// of how tall the content ends up being.
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:qpay/auth_state.dart';
|
||||
import 'package:qpay/http/http.dart';
|
||||
import 'package:qpay/models/api_response.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -60,15 +61,22 @@ class LoginController extends ChangeNotifier {
|
||||
prefs.setString('username', username);
|
||||
prefs.setString('email', response['email']);
|
||||
prefs.setString('firstName', response['firstName']);
|
||||
prefs.setString('lastName', response['lastName']);
|
||||
prefs.setString('lastName', response['lastName'] ?? '');
|
||||
prefs.setString('phone', response['phone']);
|
||||
prefs.setString('userId', response['uuid']);
|
||||
prefs.setString(
|
||||
'initials',
|
||||
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);
|
||||
} else {
|
||||
model.errorMessage =
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
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:qpay/models/responsive_policy.dart';
|
||||
import 'package:provider/provider.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';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
@@ -20,242 +15,151 @@ class LoginScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
late SharedPreferences prefs;
|
||||
late GoogleSignIn googleSignIn;
|
||||
late OnboardingController onboardingController;
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final TextEditingController _emailController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
|
||||
bool _obscurePassword = true;
|
||||
bool _loading = false;
|
||||
|
||||
List<String> scopes = <String>[
|
||||
'https://www.googleapis.com/auth/contacts.readonly',
|
||||
];
|
||||
|
||||
late LoginController _loginController;
|
||||
StreamSubscription<GoogleSignInAuthenticationEvent>? _authSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loginController = LoginController(context);
|
||||
onboardingController = Provider.of<OnboardingController>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
googleSignIn = GoogleSignIn.instance;
|
||||
if (kIsWeb) {
|
||||
googleSignIn.initialize();
|
||||
_authSubscription = googleSignIn.authenticationEvents
|
||||
.handleError(_handleAuthenticationError)
|
||||
.listen(_handleAuthenticationEvent);
|
||||
} else {
|
||||
unawaited(
|
||||
googleSignIn.initialize(serverClientId: dotenv.env['CLIENTID']),
|
||||
);
|
||||
// Pre-fill email if it was captured during registration.
|
||||
final capturedEmail = onboardingController.model.email;
|
||||
if (capturedEmail != null && capturedEmail.isNotEmpty) {
|
||||
_emailController.text = capturedEmail;
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
void dispose() {
|
||||
_authSubscription?.cancel();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_loginController.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
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(25),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
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(
|
||||
'Login',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5),
|
||||
Text(
|
||||
'Welcome back to Velocity',
|
||||
style: TextStyle(fontSize: 18),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return OnboardingLayout(
|
||||
showBackButton: true,
|
||||
trailingLabel: 'Sign up',
|
||||
trailingRoute: '/onboarding/phone',
|
||||
trailingIcon: Icons.person_add_alt_1_rounded,
|
||||
bottomLinkLabel: 'Continue as guest?',
|
||||
bottomLinkRoute: '/',
|
||||
child: OnboardingCard(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const OnboardingHero(
|
||||
icon: Icons.lock_open_rounded,
|
||||
title: 'Welcome back',
|
||||
subtitle: 'Sign in to continue with Velocity',
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
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: '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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:qpay/http/http.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class OnboardingModel {
|
||||
class RegistrationModel {
|
||||
String? email;
|
||||
String? firstName;
|
||||
String? lastName;
|
||||
String? phone;
|
||||
String? password;
|
||||
String? workflowId;
|
||||
String? username;
|
||||
GoogleSignInAccount? googleUser;
|
||||
bool isLoading = false;
|
||||
}
|
||||
|
||||
class OnboardingController extends ChangeNotifier {
|
||||
OnboardingModel model = OnboardingModel();
|
||||
RegistrationModel model = RegistrationModel();
|
||||
|
||||
void resetState() {
|
||||
model = OnboardingModel();
|
||||
model = RegistrationModel();
|
||||
}
|
||||
|
||||
void updateModel(Map mapModel) {
|
||||
model.workflowId = mapModel['workflowId'];
|
||||
model.phone = mapModel['phone'];
|
||||
model.username = mapModel['username'];
|
||||
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;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
updateGoogleUser(GoogleSignInAccount googleUser) {
|
||||
model.googleUser = googleUser;
|
||||
void updatePassword(String password) {
|
||||
model.password = password;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,30 +35,29 @@ class PhoneController extends AbstractController {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
Future<ApiResponse<Map<String, dynamic>>> register() async {
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
|
||||
Map user = {
|
||||
"username": onboardingController.model.googleUser?.email,
|
||||
"email": onboardingController.model.googleUser?.email,
|
||||
"firstName": onboardingController.model.googleUser?.displayName?.split(" ").first,
|
||||
"lastName": onboardingController.model.googleUser?.displayName?.split(" ").last,
|
||||
"password": onboardingController.model.googleUser?.id,
|
||||
"photoUrl": onboardingController.model.googleUser?.photoUrl,
|
||||
"username": onboardingController.model.email,
|
||||
"email": onboardingController.model.email,
|
||||
"firstName": onboardingController.model.firstName,
|
||||
"lastName": onboardingController.model.lastName,
|
||||
"password": onboardingController.model.password,
|
||||
"phone": onboardingController.model.phone,
|
||||
"tempUid": prefs.get("userId")
|
||||
"tempUid": prefs.get("userId"),
|
||||
};
|
||||
|
||||
try {
|
||||
model.isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
Map response = await http.post(
|
||||
'/auth/register',
|
||||
user
|
||||
user,
|
||||
);
|
||||
model.status = response['state'];
|
||||
if(response['state'] == 'failed') {
|
||||
if (response['state'] == 'failed') {
|
||||
model.errorMessage = response['message'];
|
||||
showErrorSnackBar(model.errorMessage);
|
||||
model.isLoading = false;
|
||||
@@ -79,8 +78,9 @@ class PhoneController extends AbstractController {
|
||||
);
|
||||
model.isLoading = false;
|
||||
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?",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_contacts/flutter_contacts.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/widgets/onboarding_layout.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
|
||||
class PhoneScreen extends StatefulWidget {
|
||||
@@ -14,10 +15,18 @@ class PhoneScreen extends StatefulWidget {
|
||||
|
||||
class _PhoneScreenState extends State<PhoneScreen> {
|
||||
late PhoneController phoneController;
|
||||
late OnboardingController onboardingController;
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
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
|
||||
bool _obscurePassword = true;
|
||||
bool _obscureConfirmPassword = true;
|
||||
|
||||
// Common country codes with flags and names
|
||||
final List<Map<String, String>> countryCodes = [
|
||||
@@ -48,27 +57,81 @@ class _PhoneScreenState extends State<PhoneScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
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 {
|
||||
String existingPhone = phoneNumber;
|
||||
if (existingPhone.isNotEmpty) {
|
||||
// Try to extract country code from existing phone number
|
||||
for (var country in countryCodes) {
|
||||
if (existingPhone.startsWith(country['code']!)) {
|
||||
setState(() {
|
||||
selectedCountryCode = country['code']!;
|
||||
});
|
||||
_phoneController.text = existingPhone.substring(
|
||||
country['code']!.length,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// If no country code found, use default and set the full number
|
||||
if (_phoneController.text.isEmpty) {
|
||||
_phoneController.text = existingPhone;
|
||||
}
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
_fullNameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Splits a full name into a [firstName] and [lastName] using a single
|
||||
/// space as the delimiter.
|
||||
///
|
||||
/// Examples:
|
||||
/// "John" -> firstName: "John", lastName: ""
|
||||
/// "John Doe" -> firstName: "John", lastName: "Doe"
|
||||
/// "John Michael Doe" -> firstName: "John", lastName: "Michael Doe"
|
||||
/// " 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(
|
||||
listenable: phoneController,
|
||||
builder: (context, child) {
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(25.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
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: <Widget>[
|
||||
SizedBox(height: 75),
|
||||
Text(
|
||||
'Verify Your Device',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
Text(
|
||||
'We will send you a verification code to this number',
|
||||
style: TextStyle(fontSize: 18),
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return OnboardingLayout(
|
||||
showBackButton: true,
|
||||
trailingLabel: 'Sign in',
|
||||
trailingRoute: '/onboarding/login',
|
||||
trailingIcon: Icons.login_rounded,
|
||||
bottomLinkLabel: 'Continue as guest?',
|
||||
bottomLinkRoute: '/',
|
||||
child: OnboardingCard(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const OnboardingHero(
|
||||
icon: Icons.person_add_alt_1_rounded,
|
||||
title: 'Create your account',
|
||||
subtitle:
|
||||
'It only takes a minute — we\'ll send a verification code to confirm your details.',
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
OnboardingTextField(
|
||||
controller: _fullNameController,
|
||||
hintText: 'Full name',
|
||||
prefixIcon: Icons.person_outline,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Please enter your full name';
|
||||
}
|
||||
if (value.trim().length < 2) {
|
||||
return 'Name is too short';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
),
|
||||
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() {
|
||||
return Column(
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
// Country code dropdown
|
||||
Container(
|
||||
width: 100,
|
||||
height: 55,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
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;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
// Country code dropdown
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Container(
|
||||
height: 47,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
border: Border.all(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.12),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// Phone number field
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
controller: _phoneController,
|
||||
keyboardType: TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Enter phone number',
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: selectedCountryCode,
|
||||
isExpanded: true,
|
||||
icon: const Icon(Icons.arrow_drop_down_rounded),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
items: countryCodes.map((country) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: country['code'],
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(country['flag']!),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
country['code']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a phone number';
|
||||
);
|
||||
}).toList(),
|
||||
onChanged: (String? newValue) {
|
||||
if (newValue != null) {
|
||||
setState(() {
|
||||
selectedCountryCode = newValue;
|
||||
});
|
||||
}
|
||||
// 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;
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qpay/abstracts/AbstractController.dart';
|
||||
@@ -19,7 +22,7 @@ class VerifyController extends AbstractController {
|
||||
|
||||
VerifyModel model = VerifyModel();
|
||||
|
||||
VerifyController(this.context){
|
||||
VerifyController(this.context) {
|
||||
onboardingController = Provider.of<OnboardingController>(
|
||||
context,
|
||||
listen: false,
|
||||
@@ -31,9 +34,64 @@ class VerifyController extends AbstractController {
|
||||
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 = {
|
||||
"username": onboardingController.model.googleUser?.email,
|
||||
"username": onboardingController.model.email,
|
||||
"otpCode": model.code,
|
||||
"otpType": "REGISTRATION",
|
||||
"workflowId": onboardingController.model.workflowId,
|
||||
@@ -41,62 +99,61 @@ class VerifyController extends AbstractController {
|
||||
|
||||
try {
|
||||
model.isLoading = true;
|
||||
model.errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
Map response = await http.post(
|
||||
'/auth/verify',
|
||||
user
|
||||
);
|
||||
Map response = await http.post('/auth/verify', user);
|
||||
model.status = response['state'];
|
||||
model.isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
if(response['state'] == 'failed') {
|
||||
model.errorMessage = response['message'];
|
||||
return ApiResponse.failure(response['message'] ?? "Verification failed");
|
||||
if (response['state'] == 'failed') {
|
||||
final message =
|
||||
response['message']?.toString() ?? "Verification failed";
|
||||
model.errorMessage = message;
|
||||
return ApiResponse.failure(message);
|
||||
}
|
||||
return ApiResponse.success(Map<String, dynamic>.from(response));
|
||||
} catch (e) {
|
||||
logger.e(e);
|
||||
final message = _extractErrorMessage(e);
|
||||
model.errorMessage = message;
|
||||
model.isLoading = false;
|
||||
notifyListeners();
|
||||
return ApiResponse.failure(
|
||||
"Problem during registration, please try again or contact support?",
|
||||
);
|
||||
return ApiResponse.failure(message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<ApiResponse<Map<String, dynamic>>> resendOtp () async {
|
||||
Future<ApiResponse<Map<String, dynamic>>> resendOtp() async {
|
||||
Map user = {
|
||||
"username": onboardingController.model.googleUser?.email,
|
||||
"username": onboardingController.model.email,
|
||||
"phone": onboardingController.model.phone,
|
||||
"workflowId": onboardingController.model.workflowId,
|
||||
};
|
||||
|
||||
try {
|
||||
model.isLoading = true;
|
||||
model.errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
Map response = await http.post(
|
||||
'/auth/resend',
|
||||
user
|
||||
);
|
||||
Map response = await http.post('/auth/resend', user);
|
||||
model.status = response['state'];
|
||||
model.isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
if(response['state'] == 'failed') {
|
||||
model.errorMessage = response['message'];
|
||||
return ApiResponse.failure(response['message'] ?? "Resend failed");
|
||||
if (response['state'] == 'failed') {
|
||||
final message = response['message']?.toString() ?? "Resend failed";
|
||||
model.errorMessage = message;
|
||||
return ApiResponse.failure(message);
|
||||
}
|
||||
return ApiResponse.success(Map<String, dynamic>.from(response));
|
||||
} catch (e) {
|
||||
logger.e(e);
|
||||
final message = _extractErrorMessage(e);
|
||||
model.errorMessage = message;
|
||||
model.isLoading = false;
|
||||
notifyListeners();
|
||||
return ApiResponse.failure(
|
||||
"Problem during registration, please try again or contact support?",
|
||||
);
|
||||
return ApiResponse.failure(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.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/widgets/onboarding_layout.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
|
||||
class VerifyScreen extends StatefulWidget {
|
||||
@@ -18,7 +18,6 @@ class _VerifyScreenState extends State<VerifyScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
verifyController = VerifyController(context);
|
||||
}
|
||||
|
||||
@@ -48,6 +47,37 @@ class _VerifyScreenState extends State<VerifyScreen> {
|
||||
void _onCodeChanged(String value, int index) {
|
||||
if (value.length == 1 && index < 5) {
|
||||
_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(
|
||||
listenable: verifyController,
|
||||
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(
|
||||
'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)),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return OnboardingLayout(
|
||||
showBackButton: true,
|
||||
trailingLabel: 'Sign in',
|
||||
trailingRoute: '/onboarding/login',
|
||||
trailingIcon: Icons.login_rounded,
|
||||
bottomLinkLabel: 'Continue as guest?',
|
||||
bottomLinkRoute: '/',
|
||||
child: OnboardingCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const OnboardingHero(
|
||||
icon: Icons.sms_rounded,
|
||||
title: 'Verify your account',
|
||||
subtitle:
|
||||
'Enter the 6-digit code we sent to your phone or email.',
|
||||
),
|
||||
),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
611
lib/screens/onboarding/widgets/onboarding_layout.dart
Normal file
611
lib/screens/onboarding/widgets/onboarding_layout.dart
Normal 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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,9 @@ class PayController extends ChangeNotifier {
|
||||
|
||||
// formData can come from a repeat transaction
|
||||
// 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 {
|
||||
model.isLoading = true;
|
||||
notifyListeners();
|
||||
@@ -129,7 +131,9 @@ class PayController extends ChangeNotifier {
|
||||
logger.e(s);
|
||||
model.isLoading = false;
|
||||
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) {
|
||||
logger.e(s);
|
||||
model.isLoading = false;
|
||||
@@ -170,8 +174,9 @@ class PayController extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
|
||||
List<dynamic> response = await http.get('/public/payment-processors');
|
||||
model.paymentProcessors =
|
||||
response.map((e) => PaymentProcessor.fromJson(e)).toList();
|
||||
model.paymentProcessors = response
|
||||
.map((e) => PaymentProcessor.fromJson(e))
|
||||
.toList();
|
||||
|
||||
model.isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/pay/pay_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:skeletonizer/skeletonizer.dart';
|
||||
|
||||
@@ -65,7 +66,7 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
|
||||
controller = PayController(context);
|
||||
controller.getPaymentProcessors();
|
||||
|
||||
controller.getProducts(controller.model.selectedProvider?.uid ?? "");
|
||||
controller.getProducts(controller.model.selectedProvider?.id ?? "");
|
||||
transactionController = Provider.of<TransactionController>(
|
||||
context,
|
||||
listen: false,
|
||||
@@ -169,17 +170,11 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
|
||||
|
||||
if (!apiResponse.isSuccess) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
controller.model.errorMessage ??
|
||||
apiResponse.error ??
|
||||
"Transaction failed",
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
width: 600,
|
||||
backgroundColor: Colors.deepOrange,
|
||||
),
|
||||
AppSnackBar.showError(
|
||||
context,
|
||||
controller.model.errorMessage ??
|
||||
apiResponse.error ??
|
||||
"Transaction failed",
|
||||
);
|
||||
}
|
||||
} else if (controller.model.status == "SUCCESS" && context.mounted) {
|
||||
@@ -303,15 +298,46 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
|
||||
if (showAdditionalRecipientDetails)
|
||||
_buildAdditionalRecipientDetails(),
|
||||
const SizedBox(height: 7),
|
||||
if (controller.model.products.isNotEmpty)
|
||||
if (controller.model.products.isNotEmpty ||
|
||||
controller.model.isLoading)
|
||||
_buildBillProductSelector(controller),
|
||||
const SizedBox(height: 7),
|
||||
_buildPhoneField(),
|
||||
const SizedBox(height: 7),
|
||||
_buildAmountField(),
|
||||
const SizedBox(height: 20),
|
||||
...controller.model.paymentProcessors.map(
|
||||
(e) => _buildPaymentProcessorItem(e, context),
|
||||
LayoutBuilder(
|
||||
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) {
|
||||
return Column(
|
||||
children: [
|
||||
Skeletonizer(
|
||||
enabled: controller.model.isLoading,
|
||||
child: SlideTransition(
|
||||
position:
|
||||
_animations[controller.model.paymentProcessors.indexOf(e)],
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.black87.withAlpha(20)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.1),
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.tertiary.withValues(alpha: 0.05),
|
||||
],
|
||||
stops: const [0.3, 0.7],
|
||||
begin: Alignment.topRight,
|
||||
end: Alignment.bottomLeft,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
customBorder: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
onTap: () async {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
if (controller.model.isLoading) return;
|
||||
final rawIndex = controller.model.paymentProcessors.indexOf(e);
|
||||
final animation = rawIndex < _animations.length
|
||||
? _animations[rawIndex]
|
||||
: _animations.isNotEmpty
|
||||
? _animations.last
|
||||
: Tween<Offset>(
|
||||
begin: Offset.zero,
|
||||
end: Offset.zero,
|
||||
).animate(_controller);
|
||||
return Skeletonizer(
|
||||
enabled: controller.model.isLoading,
|
||||
child: SlideTransition(
|
||||
position: animation,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.black87.withAlpha(20)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
|
||||
Theme.of(context).colorScheme.tertiary.withValues(alpha: 0.05),
|
||||
],
|
||||
stops: const [0.3, 0.7],
|
||||
begin: Alignment.topRight,
|
||||
end: Alignment.bottomLeft,
|
||||
),
|
||||
),
|
||||
child: InkWell(
|
||||
customBorder: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
onTap: () async {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
if (controller.model.isLoading) return;
|
||||
|
||||
await _submitPayment(e, context);
|
||||
}
|
||||
},
|
||||
child: ListTile(
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Image(image: AssetImage("assets/${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),
|
||||
),
|
||||
await _submitPayment(e, context);
|
||||
}
|
||||
},
|
||||
child: ListTile(
|
||||
leading: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Image(image: AssetImage("assets/${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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +92,9 @@ class _PollScreenState extends State<PollScreen> {
|
||||
'successful. This won\'t take long.',
|
||||
status: controller.model.status,
|
||||
isLoading: controller.model.isLoading,
|
||||
errorMessage: 'We failed to check your transaction status',
|
||||
errorMessage:
|
||||
controller.model.errorMessage ??
|
||||
'We failed to check your transaction status',
|
||||
onRetry: () {
|
||||
controller.model.isCancelled = false;
|
||||
_startPolling();
|
||||
|
||||
@@ -259,7 +259,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Animated info card section
|
||||
FadeTransition(
|
||||
@@ -272,7 +272,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Animated content section
|
||||
FadeTransition(
|
||||
@@ -386,7 +386,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
],
|
||||
stops: const [0.0, 0.5, 1.0],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: primaryColor.withValues(alpha: 0.12),
|
||||
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(
|
||||
children: [
|
||||
// Status badge
|
||||
if (status != null) ...[
|
||||
_buildStatusBadge(status, theme),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
// Amount row
|
||||
@@ -433,14 +433,14 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Bill name (if available)
|
||||
if (billName.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 6,
|
||||
horizontal: 12,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: primaryColor.withValues(alpha: 0.08),
|
||||
@@ -455,7 +455,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Date
|
||||
if (createdAt != null)
|
||||
@@ -467,11 +467,11 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
color: isDark ? Colors.white54 : Colors.grey.shade500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// Divider with dots pattern
|
||||
_buildDottedDivider(isDark),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// Action buttons row
|
||||
_buildActionButtons(theme),
|
||||
@@ -487,7 +487,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
Widget _buildDottedDivider(bool isDark) {
|
||||
return Row(
|
||||
children: List.generate(
|
||||
60,
|
||||
40,
|
||||
(index) => Expanded(
|
||||
child: Container(
|
||||
height: 1.5,
|
||||
@@ -508,8 +508,8 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
final paymentStatus = _transaction?.paymentStatus;
|
||||
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 8,
|
||||
spacing: 10,
|
||||
runSpacing: 6,
|
||||
alignment: WrapAlignment.center,
|
||||
children: [
|
||||
_modernActionButton(
|
||||
@@ -926,7 +926,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -954,7 +954,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Divider
|
||||
Container(
|
||||
@@ -963,12 +963,12 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
? Colors.white.withValues(alpha: 0.06)
|
||||
: Colors.grey.shade200,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// Detail items
|
||||
...items.map(
|
||||
(item) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 14),
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _buildModernDetailRow(
|
||||
theme: theme,
|
||||
isDark: isDark,
|
||||
@@ -980,7 +980,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
// Total amount divider & total
|
||||
if (totalAmount != null) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Container(
|
||||
height: 1,
|
||||
decoration: BoxDecoration(
|
||||
@@ -993,7 +993,7 @@ class _ReceiptScreenState extends State<ReceiptScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
|
||||
@@ -20,14 +20,14 @@ class RecipientModel {
|
||||
@freezed
|
||||
abstract class Recipient with _$Recipient {
|
||||
const factory Recipient({
|
||||
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,
|
||||
}) = _Recipient;
|
||||
|
||||
factory Recipient.fromJson(Map<String, dynamic> json) =>
|
||||
@@ -40,14 +40,15 @@ class RecipientsController extends ChangeNotifier {
|
||||
BuildContext context;
|
||||
|
||||
RecipientsController(this.context) {
|
||||
model.selectedProvider =
|
||||
Provider.of<TransactionController>(
|
||||
context,
|
||||
listen: false,
|
||||
).model.selectedProvider;
|
||||
model.selectedProvider = Provider.of<TransactionController>(
|
||||
context,
|
||||
listen: false,
|
||||
).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.isLoading = true;
|
||||
model.recipients = getDummyRecipients();
|
||||
|
||||
@@ -15,7 +15,7 @@ T _$identity<T>(T value) => value;
|
||||
/// @nodoc
|
||||
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
|
||||
/// with the given fields replaced by the non-null parameter values.
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
@@ -48,7 +48,7 @@ abstract mixin class $RecipientCopyWith<$Res> {
|
||||
factory $RecipientCopyWith(Recipient value, $Res Function(Recipient) _then) = _$RecipientCopyWithImpl;
|
||||
@useResult
|
||||
$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) {
|
||||
case _Recipient() when $default != null:
|
||||
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) {
|
||||
case _Recipient():
|
||||
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) {
|
||||
case _Recipient() when $default != null:
|
||||
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()
|
||||
|
||||
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);
|
||||
|
||||
@override final String? uid;
|
||||
@override final String? name;
|
||||
@override final String? email;
|
||||
@override final String? phoneNumber;
|
||||
@override final String? address;
|
||||
@override final String? account;
|
||||
@override final String? initials;
|
||||
@override final String? latestProviderLabel;
|
||||
@override@JsonKey(includeIfNull: false) final String? uid;
|
||||
@override@JsonKey(includeIfNull: false) final String? name;
|
||||
@override@JsonKey(includeIfNull: false) final String? email;
|
||||
@override@JsonKey(includeIfNull: false) final String? phoneNumber;
|
||||
@override@JsonKey(includeIfNull: false) final String? address;
|
||||
@override@JsonKey(includeIfNull: false) final String? account;
|
||||
@override@JsonKey(includeIfNull: false) final String? initials;
|
||||
@override@JsonKey(includeIfNull: false) final String? latestProviderLabel;
|
||||
|
||||
/// Create a copy of Recipient
|
||||
/// 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;
|
||||
@override @useResult
|
||||
$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
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -19,12 +19,12 @@ _Recipient _$RecipientFromJson(Map<String, dynamic> json) => _Recipient(
|
||||
|
||||
Map<String, dynamic> _$RecipientToJson(_Recipient instance) =>
|
||||
<String, dynamic>{
|
||||
'uid': instance.uid,
|
||||
'name': instance.name,
|
||||
'email': instance.email,
|
||||
'phoneNumber': instance.phoneNumber,
|
||||
'address': instance.address,
|
||||
'account': instance.account,
|
||||
'initials': instance.initials,
|
||||
'latestProviderLabel': instance.latestProviderLabel,
|
||||
'uid': ?instance.uid,
|
||||
'name': ?instance.name,
|
||||
'email': ?instance.email,
|
||||
'phoneNumber': ?instance.phoneNumber,
|
||||
'address': ?instance.address,
|
||||
'account': ?instance.account,
|
||||
'initials': ?instance.initials,
|
||||
'latestProviderLabel': ?instance.latestProviderLabel,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gif_view/gif_view.dart';
|
||||
import 'package:qpay/main.dart';
|
||||
import 'package:qpay/routes.dart';
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
77
lib/widgets/app_snack_bar.dart
Normal file
77
lib/widgets/app_snack_bar.dart
Normal 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);
|
||||
}
|
||||
}
|
||||
79
lib/widgets/status_chip_widget.dart
Normal file
79
lib/widgets/status_chip_widget.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -93,15 +93,16 @@ class _StepLoadingWidgetState extends State<StepLoadingWidget>
|
||||
const SizedBox(height: 60),
|
||||
|
||||
// Animated loader
|
||||
if (widget.status != 'FAILED' &&
|
||||
widget.status != 'SUCCESS')
|
||||
if (widget.status != 'FAILED' && widget.status != 'SUCCESS')
|
||||
AnimatedBuilder(
|
||||
animation: Listenable.merge(
|
||||
[_pulseController, _spinController]),
|
||||
animation: Listenable.merge([
|
||||
_pulseController,
|
||||
_spinController,
|
||||
]),
|
||||
builder: (context, child) {
|
||||
return SizedBox(
|
||||
width: 120,
|
||||
height: 120,
|
||||
width: 80,
|
||||
height: 80,
|
||||
child: CustomPaint(
|
||||
painter: _LoadingPainter(
|
||||
pulseValue: _pulseAnimation.value,
|
||||
@@ -113,11 +114,9 @@ class _StepLoadingWidgetState extends State<StepLoadingWidget>
|
||||
},
|
||||
),
|
||||
|
||||
if (widget.status == 'SUCCESS')
|
||||
_buildCheckIcon(primaryColor),
|
||||
if (widget.status == 'SUCCESS') _buildCheckIcon(primaryColor),
|
||||
|
||||
if (widget.status == 'FAILED')
|
||||
_buildErrorIcon(theme),
|
||||
if (widget.status == 'FAILED') _buildErrorIcon(theme),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
@@ -152,8 +151,7 @@ class _StepLoadingWidgetState extends State<StepLoadingWidget>
|
||||
Column(
|
||||
children: [
|
||||
Text(
|
||||
widget.errorMessage ??
|
||||
'Something went wrong',
|
||||
widget.errorMessage ?? 'Something went wrong',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: theme.colorScheme.error,
|
||||
@@ -172,10 +170,7 @@ class _StepLoadingWidgetState extends State<StepLoadingWidget>
|
||||
horizontal: 32,
|
||||
vertical: 14,
|
||||
),
|
||||
side: BorderSide(
|
||||
color: primaryColor,
|
||||
width: 1.5,
|
||||
),
|
||||
side: BorderSide(color: primaryColor, width: 1.5),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
@@ -195,24 +190,20 @@ class _StepLoadingWidgetState extends State<StepLoadingWidget>
|
||||
|
||||
Widget _buildCheckIcon(Color primaryColor) {
|
||||
return Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: primaryColor.withValues(alpha: 0.1),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.check_circle_rounded,
|
||||
size: 80,
|
||||
color: primaryColor,
|
||||
),
|
||||
child: Icon(Icons.check_circle_rounded, size: 80, color: primaryColor),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorIcon(ThemeData theme) {
|
||||
return Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: theme.colorScheme.error.withValues(alpha: 0.1),
|
||||
@@ -264,9 +255,7 @@ class _StepIndicator extends StatelessWidget {
|
||||
height: 2,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: currentStep >= 2
|
||||
? primaryColor
|
||||
: Colors.grey.shade300,
|
||||
color: currentStep >= 2 ? primaryColor : Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
),
|
||||
@@ -323,10 +312,7 @@ class _StepIndicator extends StatelessWidget {
|
||||
Container(
|
||||
width: 32,
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: circleColor,
|
||||
),
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: circleColor),
|
||||
child: Center(child: circleChild),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
@@ -335,9 +321,7 @@ class _StepIndicator extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
|
||||
color: isActive
|
||||
? theme.colorScheme.tertiary
|
||||
: Colors.grey.shade500,
|
||||
color: isActive ? theme.colorScheme.tertiary : Colors.grey.shade500,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -40,11 +40,11 @@ static void my_application_activate(GApplication* application) {
|
||||
if (use_header_bar) {
|
||||
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||
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_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||
} else {
|
||||
gtk_window_set_title(window, "qpay");
|
||||
gtk_window_set_title(window, "Velocity Pay");
|
||||
}
|
||||
|
||||
gtk_window_set_default_size(window, 1280, 720);
|
||||
|
||||
@@ -6,7 +6,6 @@ import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import firebase_core
|
||||
import google_sign_in_ios
|
||||
import path_provider_foundation
|
||||
import share_plus
|
||||
import shared_preferences_foundation
|
||||
@@ -15,7 +14,6 @@ import webview_flutter_wkwebview
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
|
||||
38
nginx.conf
Normal file
38
nginx.conf
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
68
pubspec.lock
68
pubspec.lock
@@ -237,26 +237,26 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_core
|
||||
sha256: "132e1c311bc41e7d387b575df0aacdf24efbf4930365eb61042be5bde3978f03"
|
||||
sha256: ec46a100a560d3bd5f97f2d89ba7492cb09b6dd0a4a28753d1258f360d6bd9f9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.0"
|
||||
version: "4.10.0"
|
||||
firebase_core_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_platform_interface
|
||||
sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64
|
||||
sha256: "4a120366dbf7d5a8ee9438978530b664b855728fb8dcc3a201017660817e555b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.2"
|
||||
version: "7.0.1"
|
||||
firebase_core_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_web
|
||||
sha256: ecde2def458292404a4fcd3731ee4992fd631a0ec359d2d67c33baa8da5ec8ae
|
||||
sha256: "5ad1be848692ec148f2d6a8ad2a3838cb852ea5f3c9e6479a7afce479e1854f8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
version: "3.8.0"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -360,54 +360,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -728,6 +680,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
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:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -53,12 +53,10 @@ dependencies:
|
||||
share_plus: ^11.0.0
|
||||
flutter_contacts: ^1.1.9+2
|
||||
gif_view: ^0.4.0
|
||||
google_sign_in: ^7.1.1
|
||||
firebase_core: ^4.1.0
|
||||
url_launcher: ^6.3.2
|
||||
html: ^0.15.6
|
||||
web: ^1.1.1
|
||||
google_sign_in_web: ^1.1.0
|
||||
flutter_web_plugins:
|
||||
sdk: flutter
|
||||
decimal: ^3.2.4
|
||||
@@ -77,6 +75,7 @@ dev_dependencies:
|
||||
build_runner: ^2.5.4
|
||||
freezed: ^3.0.6
|
||||
json_serializable: ^6.9.5
|
||||
rename_app: ^1.6.6
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
@@ -128,3 +127,6 @@ icons_launcher:
|
||||
enable: true
|
||||
web:
|
||||
enable: true
|
||||
|
||||
config_name:
|
||||
name: "Velocity Pay"
|
||||
|
||||
BIN
upload-keystore.jks
Normal file
BIN
upload-keystore.jks
Normal file
Binary file not shown.
@@ -1,35 +1,35 @@
|
||||
{
|
||||
"name": "Velocity",
|
||||
"short_name": "Velocity",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#ffffff",
|
||||
"description": "Payments... but faster",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/Icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
"name": "Velocity Pay",
|
||||
"short_name": "Velocity Pay",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#ffffff",
|
||||
"description": "Payments... but faster",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/Icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
FlutterWindow window(project);
|
||||
Win32Window::Point origin(10, 10);
|
||||
Win32Window::Size size(1280, 720);
|
||||
if (!window.Create(L"qpay", origin, size)) {
|
||||
if (!window.Create(L"Velocity Pay", origin, size)) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
window.SetQuitOnClose(true);
|
||||
|
||||
Reference in New Issue
Block a user