completed group batch feature
This commit is contained in:
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`
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import java.util.Properties
|
||||||
|
import java.io.FileInputStream
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
// START: FlutterFire Configuration
|
// START: FlutterFire Configuration
|
||||||
@@ -8,6 +11,14 @@ plugins {
|
|||||||
id("dev.flutter.flutter-gradle-plugin")
|
id("dev.flutter.flutter-gradle-plugin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
val keystoreProperties = Properties()
|
||||||
|
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||||
|
if (keystorePropertiesFile.exists()) {
|
||||||
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "zw.co.qantra.qpay"
|
namespace = "zw.co.qantra.qpay"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -34,11 +45,21 @@ android {
|
|||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
create("release") {
|
||||||
|
keyAlias = keystoreProperties["keyAlias"] as String
|
||||||
|
keyPassword = keystoreProperties["keyPassword"] as String
|
||||||
|
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
|
||||||
|
storePassword = keystoreProperties["storePassword"] as String
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// TODO: Add your own signing config for the release build.
|
// TODO: Add your own signing config for the release build.
|
||||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
// signingConfig = signingConfigs.getByName("debug")
|
||||||
|
signingConfig = signingConfigs.getByName("release")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,16 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||||
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
|
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
|
||||||
<application
|
<application android:label="Velocity Pay" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" android:forceDarkAllowed="false">
|
||||||
android:label="Velocity"
|
|
||||||
android:name="${applicationName}"
|
|
||||||
android:icon="@mipmap/ic_launcher"
|
|
||||||
android:forceDarkAllowed="false">
|
|
||||||
|
|
||||||
<!-- Main Activity -->
|
<!-- Main Activity -->
|
||||||
<activity
|
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:taskAffinity="" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
|
||||||
android:name=".MainActivity"
|
|
||||||
android:exported="true"
|
|
||||||
android:launchMode="singleTop"
|
|
||||||
android:taskAffinity=""
|
|
||||||
android:theme="@style/LaunchTheme"
|
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
|
||||||
android:hardwareAccelerated="true"
|
|
||||||
android:windowSoftInputMode="adjustResize">
|
|
||||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||||
the Android process has started. This theme is visible to the user
|
the Android process has started. This theme is visible to the user
|
||||||
while the Flutter UI initializes. After that, this theme continues
|
while the Flutter UI initializes. After that, this theme continues
|
||||||
to determine the Window background behind the Flutter UI. -->
|
to determine the Window background behind the Flutter UI. -->
|
||||||
<meta-data
|
<meta-data android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme"/>
|
||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
|
||||||
android:resource="@style/NormalTheme"
|
|
||||||
/>
|
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
@@ -33,9 +18,7 @@
|
|||||||
</activity>
|
</activity>
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data android:name="flutterEmbedding" android:value="2"/>
|
||||||
android:name="flutterEmbedding"
|
|
||||||
android:value="2" />
|
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|||||||
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 |
@@ -1,11 +1,11 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Velocity</string>
|
<string>Velocity Pay</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>Velocity</string>
|
<string>Velocity Pay</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
@@ -59,6 +59,5 @@
|
|||||||
<string>To fetch recipient phone numbers</string>
|
<string>To fetch recipient phone numbers</string>
|
||||||
<key>UIUserInterfaceStyle</key>
|
<key>UIUserInterfaceStyle</key>
|
||||||
<string>Light</string>
|
<string>Light</string>
|
||||||
|
</dict>
|
||||||
</dict>
|
|
||||||
</plist>
|
</plist>
|
||||||
@@ -1,16 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||||
|
|
||||||
class AbstractController extends ChangeNotifier {
|
class AbstractController extends ChangeNotifier {
|
||||||
late BuildContext context;
|
late BuildContext context;
|
||||||
void showErrorSnackBar(String? message) {
|
void showErrorSnackBar(String? message) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppSnackBar.showError(context, message.toString());
|
||||||
SnackBar(
|
|
||||||
content: Text(message.toString()),
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
backgroundColor: Colors.deepOrange,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
87
lib/auth_state.dart
Normal file
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;
|
||||||
253
lib/main.dart
253
lib/main.dart
@@ -1,38 +1,10 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
|
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:qpay/auth_state.dart';
|
||||||
import 'package:qpay/navbar.dart';
|
import 'package:qpay/routes.dart';
|
||||||
import 'package:qpay/screens/confirm/confirm_screen.dart';
|
|
||||||
import 'package:qpay/screens/gateway/gateway_redirect.dart';
|
|
||||||
import 'package:qpay/screens/gateway/gateway_screen.dart';
|
|
||||||
import 'package:qpay/screens/gateway/gateway_web_screen.dart';
|
|
||||||
import 'package:qpay/screens/home/home_screen.dart';
|
|
||||||
import 'package:qpay/screens/history/history_screen.dart';
|
|
||||||
import 'package:qpay/screens/integration/integration_screen.dart';
|
|
||||||
import 'package:qpay/screens/onboarding/complete_screen.dart';
|
|
||||||
import 'package:qpay/screens/onboarding/landing/landing_screen.dart';
|
|
||||||
import 'package:qpay/screens/onboarding/login/login_screen.dart';
|
|
||||||
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
|
|
||||||
import 'package:qpay/screens/onboarding/password/password_screen.dart';
|
|
||||||
import 'package:qpay/screens/onboarding/phone/phone_screen.dart';
|
|
||||||
import 'package:qpay/screens/onboarding/register/register_screen.dart';
|
|
||||||
import 'package:qpay/screens/onboarding/verify/verify_screen.dart';
|
|
||||||
import 'package:qpay/screens/pay/pay_screen.dart';
|
|
||||||
import 'package:qpay/screens/poll/poll_screen.dart';
|
|
||||||
import 'package:qpay/screens/splash_screen.dart';
|
|
||||||
import 'package:qpay/screens/receipt/receipt_screen.dart';
|
|
||||||
import 'package:qpay/screens/recipient/recipients_screen.dart';
|
|
||||||
import 'package:qpay/screens/transaction_controller.dart';
|
import 'package:qpay/screens/transaction_controller.dart';
|
||||||
import 'package:qpay/screens/profile_screen.dart';
|
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
|
||||||
import 'package:qpay/screens/groups/groups_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/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/models/recipient_group_model.dart';
|
|
||||||
import 'package:qpay/screens/groups/models/group_batch_model.dart';
|
|
||||||
import 'package:qpay/theme/app_theme.dart';
|
import 'package:qpay/theme/app_theme.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
@@ -52,43 +24,19 @@ String resolveEnvFile(String env) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tracks whether the splash animation is complete so that
|
|
||||||
/// GoRouter's redirect can show the splash first, then release
|
|
||||||
/// navigation to the originally intended URL.
|
|
||||||
class SplashState extends ChangeNotifier {
|
|
||||||
bool _complete = false;
|
|
||||||
|
|
||||||
/// The route the user originally intended to visit before being
|
|
||||||
/// redirected to /splash. Set by the redirect callback on first
|
|
||||||
/// invocation.
|
|
||||||
String? _intendedRoute;
|
|
||||||
bool get complete => _complete;
|
|
||||||
String? get intendedRoute => _intendedRoute;
|
|
||||||
|
|
||||||
/// Called by the redirect to store the intended destination and
|
|
||||||
/// return the splash route.
|
|
||||||
String? guard(String attemptedRoute) {
|
|
||||||
if (_complete) return null;
|
|
||||||
// Avoid saving the splash route itself.
|
|
||||||
if (attemptedRoute == '/splash') return null;
|
|
||||||
_intendedRoute = attemptedRoute;
|
|
||||||
return '/splash';
|
|
||||||
}
|
|
||||||
|
|
||||||
void finish() {
|
|
||||||
_complete = true;
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final SplashState splashState = SplashState();
|
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
usePathUrlStrategy();
|
usePathUrlStrategy();
|
||||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||||
|
|
||||||
await dotenv.load(fileName: resolveEnvFile(appEnv));
|
await dotenv.load(fileName: resolveEnvFile(appEnv));
|
||||||
|
|
||||||
|
// Read the persisted auth token before the first frame so the router
|
||||||
|
// knows whether the user is already signed in on cold start. Without
|
||||||
|
// this we would briefly treat logged-in users as guests and bounce
|
||||||
|
// them to the sign-in screen until the first refresh kicked in.
|
||||||
|
await authState.initialize();
|
||||||
|
|
||||||
runApp(
|
runApp(
|
||||||
MultiProvider(
|
MultiProvider(
|
||||||
providers: [
|
providers: [
|
||||||
@@ -119,186 +67,7 @@ class _MyAppState extends State<MyApp> {
|
|||||||
theme: AppTheme.lightTheme,
|
theme: AppTheme.lightTheme,
|
||||||
themeMode: ThemeMode.light,
|
themeMode: ThemeMode.light,
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
routerConfig: GoRouter(
|
routerConfig: buildRouter(),
|
||||||
initialLocation: '/',
|
|
||||||
refreshListenable: splashState,
|
|
||||||
errorBuilder: (context, state) => Scaffold(
|
|
||||||
body: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Text('Page not found', style: TextStyle(fontSize: 20)),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () => context.go('/'),
|
|
||||||
child: const Text('Go Home'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
redirect: (context, state) {
|
|
||||||
// Before splash is complete: redirect everything to /splash,
|
|
||||||
// but save the original intended destination.
|
|
||||||
final guardResult = splashState.guard(state.uri.path);
|
|
||||||
if (guardResult != null) return guardResult;
|
|
||||||
|
|
||||||
// Once splash is complete: if we're still on /splash, navigate
|
|
||||||
// to the intended destination (or home as fallback).
|
|
||||||
if (splashState.complete && state.uri.path == '/splash') {
|
|
||||||
return splashState.intendedRoute ?? '/';
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
routes: [
|
|
||||||
// Splash route sits outside the ShellRoute so it renders
|
|
||||||
// without the bottom nav bar or side rail.
|
|
||||||
GoRoute(
|
|
||||||
path: '/splash',
|
|
||||||
builder: (context, state) => const SplashScreen(),
|
|
||||||
),
|
|
||||||
ShellRoute(
|
|
||||||
builder: (context, state, child) {
|
|
||||||
final location = GoRouterState.of(context).uri.toString();
|
|
||||||
if (location.startsWith('/onboarding/')) {
|
|
||||||
return child;
|
|
||||||
}
|
|
||||||
return ScaffoldWithNavBar(child: child);
|
|
||||||
},
|
|
||||||
routes: [
|
|
||||||
GoRoute(
|
|
||||||
path: '/',
|
|
||||||
builder: (context, state) => const HomeScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/home',
|
|
||||||
builder: (context, state) => const HomeScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/make-payment',
|
|
||||||
builder: (context, state) => const PayScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/confirm',
|
|
||||||
builder: (context, state) => const ConfirmScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/gateway',
|
|
||||||
builder: (context, state) => const GatewayScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/gateway-web',
|
|
||||||
builder: (context, state) => const GatewayWebScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/gateway-redirect',
|
|
||||||
builder: (context, state) => const GatewayRedirectScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/poll/:id',
|
|
||||||
builder: (context, state) =>
|
|
||||||
PollScreen(transactionId: state.pathParameters['id']),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/poll',
|
|
||||||
builder: (context, state) => const PollScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/receipt',
|
|
||||||
builder: (context, state) => const ReceiptScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/receipt/:transactionId',
|
|
||||||
builder: (context, state) => ReceiptScreen(
|
|
||||||
transactionId: state.pathParameters['transactionId'],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/integration',
|
|
||||||
builder: (context, state) => const IntegrationScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/recipients',
|
|
||||||
builder: (context, state) => const RecipientsScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/history',
|
|
||||||
builder: (context, state) => const HistoryScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/profile',
|
|
||||||
builder: (context, state) => const ProfileScreen(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
|
||||||
path: '/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/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(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
168
lib/navbar.dart
168
lib/navbar.dart
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
import 'models/responsive_policy.dart';
|
import 'models/responsive_policy.dart';
|
||||||
|
|
||||||
@@ -43,13 +44,22 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
|||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
NavigationRail(
|
SizedBox(
|
||||||
|
width: 256,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: NavigationRail(
|
||||||
extended: true,
|
extended: true,
|
||||||
leading: Image(
|
leading: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 12.0,
|
||||||
|
),
|
||||||
|
child: Image(
|
||||||
image: AssetImage('assets/velocity.png'),
|
image: AssetImage('assets/velocity.png'),
|
||||||
width: 150,
|
width: 150,
|
||||||
),
|
),
|
||||||
trailing: _buildLogin(context),
|
),
|
||||||
labelType: NavigationRailLabelType.none,
|
labelType: NavigationRailLabelType.none,
|
||||||
indicatorColor: Theme.of(
|
indicatorColor: Theme.of(
|
||||||
context,
|
context,
|
||||||
@@ -60,6 +70,18 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
|||||||
selectedIndex: _calculateSelectedIndex(context),
|
selectedIndex: _calculateSelectedIndex(context),
|
||||||
destinations: _buildNavigationRailDestinations(),
|
destinations: _buildNavigationRailDestinations(),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 1,
|
||||||
|
color: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary.withAlpha(30),
|
||||||
|
),
|
||||||
|
_buildFooterLinks(context),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
VerticalDivider(
|
VerticalDivider(
|
||||||
thickness: 1,
|
thickness: 1,
|
||||||
width: 1,
|
width: 1,
|
||||||
@@ -134,56 +156,6 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildLogin(BuildContext context) {
|
|
||||||
if (_isLoggedIn) {
|
|
||||||
return SizedBox();
|
|
||||||
}
|
|
||||||
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 220,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
border: Border.all(color: Colors.black87.withAlpha(20)),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 10, horizontal: 20),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
spacing: 10,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Register or Login for more features',
|
|
||||||
style: TextStyle(fontSize: 16),
|
|
||||||
),
|
|
||||||
InkWell(
|
|
||||||
onTap: () {
|
|
||||||
context.push('/onboarding/landing');
|
|
||||||
},
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: Container(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.primary.withValues(alpha: 0.15),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Text('Get Started', style: TextStyle(fontSize: 14)),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: 10),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleTap(int index, BuildContext context) {
|
void _handleTap(int index, BuildContext context) {
|
||||||
switch (index) {
|
switch (index) {
|
||||||
case 0:
|
case 0:
|
||||||
@@ -204,4 +176,94 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
|||||||
if (location.startsWith('/history')) return 2;
|
if (location.startsWith('/history')) return 2;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Footer link entries shown at the bottom of the web navigation rail.
|
||||||
|
/// Each entry pairs the visible label with the external URL it should
|
||||||
|
/// open when tapped.
|
||||||
|
static const List<Map<String, String>> _footerLinkEntries = [
|
||||||
|
{'label': 'About', 'url': 'https://velocityafrica.net'},
|
||||||
|
{'label': 'FAQs', 'url': 'https://velocityafrica.net'},
|
||||||
|
{'label': 'Privacy', 'url': 'https://qantra.co.zw/privacy'},
|
||||||
|
{'label': 'Terms', 'url': 'https://qantra.co.zw/terms'},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Opens the supplied [url] in an external browser using
|
||||||
|
/// `url_launcher`. Errors are swallowed because the navbar footer
|
||||||
|
/// is non-critical UI.
|
||||||
|
Future<void> _openExternalLink(String url) async {
|
||||||
|
final uri = Uri.tryParse(url);
|
||||||
|
if (uri == null) return;
|
||||||
|
try {
|
||||||
|
final launched = await launchUrl(
|
||||||
|
uri,
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
);
|
||||||
|
if (!launched && mounted) {
|
||||||
|
await launchUrl(uri, mode: LaunchMode.platformDefault);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Intentionally silent — footer links are best-effort.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the footer section at the bottom of the web navigation rail.
|
||||||
|
/// Renders the About / FAQs / Privacy / Terms links (each opening
|
||||||
|
/// the configured external URL) followed by a copyright line.
|
||||||
|
Widget _buildFooterLinks(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
|
final mutedTextColor = isDark ? Colors.white60 : Colors.black54;
|
||||||
|
final linkTextColor = isDark ? Colors.white : Colors.black87;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 4,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < _footerLinkEntries.length; i++) ...[
|
||||||
|
InkWell(
|
||||||
|
onTap: () => _openExternalLink(_footerLinkEntries[i]['url']!),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 4,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_footerLinkEntries[i]['label']!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: linkTextColor,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (i != _footerLinkEntries.length - 1)
|
||||||
|
Text(
|
||||||
|
'·',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: mutedTextColor,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'© 2026 Velocity Africa. All rights reserved.',
|
||||||
|
style: TextStyle(fontSize: 10, color: mutedTextColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
282
lib/routes.dart
Normal file
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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:qpay/models/api_response.dart';
|
import 'package:qpay/models/api_response.dart';
|
||||||
import 'package:qpay/screens/transaction_controller.dart';
|
import 'package:qpay/screens/transaction_controller.dart';
|
||||||
|
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||||
|
|
||||||
import '../../http/http.dart';
|
import '../../http/http.dart';
|
||||||
|
|
||||||
@@ -43,13 +44,7 @@ class ConfirmController extends ChangeNotifier {
|
|||||||
|
|
||||||
void _showErrorSnackBar(String? message) {
|
void _showErrorSnackBar(String? message) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppSnackBar.showError(context, message.toString());
|
||||||
SnackBar(
|
|
||||||
content: Text(message.toString()),
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
backgroundColor: Colors.deepOrange,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:qpay/models/responsive_policy.dart';
|
import 'package:qpay/models/responsive_policy.dart';
|
||||||
import 'package:qpay/screens/confirm/confirm_controller.dart';
|
import 'package:qpay/screens/confirm/confirm_controller.dart';
|
||||||
|
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
|
|
||||||
class ConfirmScreen extends StatefulWidget {
|
class ConfirmScreen extends StatefulWidget {
|
||||||
@@ -65,17 +66,11 @@ class _ConfirmScreenState extends State<ConfirmScreen>
|
|||||||
final response = await controller.doTransaction();
|
final response = await controller.doTransaction();
|
||||||
if (!response.isSuccess) {
|
if (!response.isSuccess) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppSnackBar.showError(
|
||||||
SnackBar(
|
context,
|
||||||
content: Text(
|
|
||||||
controller.model.errorMessage ??
|
controller.model.errorMessage ??
|
||||||
response.error ??
|
response.error ??
|
||||||
"Transaction failed",
|
"Transaction failed",
|
||||||
),
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
width: 600,
|
|
||||||
backgroundColor: Colors.deepOrange,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
|||||||
import 'package:qpay/http/http.dart';
|
import 'package:qpay/http/http.dart';
|
||||||
import 'package:qpay/models/api_response.dart';
|
import 'package:qpay/models/api_response.dart';
|
||||||
import 'package:qpay/screens/transaction_controller.dart';
|
import 'package:qpay/screens/transaction_controller.dart';
|
||||||
|
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
class GatewayModel {
|
class GatewayModel {
|
||||||
@@ -30,13 +31,7 @@ class GatewayController extends ChangeNotifier {
|
|||||||
|
|
||||||
void _showErrorSnackBar(String? message) {
|
void _showErrorSnackBar(String? message) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppSnackBar.showError(context, message.toString());
|
||||||
SnackBar(
|
|
||||||
content: Text(message.toString()),
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
backgroundColor: Colors.deepOrange,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:qpay/screens/gateway/gateway_controller.dart';
|
import 'package:qpay/screens/gateway/gateway_controller.dart';
|
||||||
|
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:webview_flutter/webview_flutter.dart';
|
||||||
|
|
||||||
class GatewayScreen extends StatefulWidget {
|
class GatewayScreen extends StatefulWidget {
|
||||||
@@ -69,15 +70,11 @@ class _GatewayScreenState extends State<GatewayScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppSnackBar.show(
|
||||||
SnackBar(
|
context,
|
||||||
content: Text(
|
'Payment was successful, please wait while we process your order.',
|
||||||
"Payment was successful, please wait while we process your order.",
|
|
||||||
),
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
backgroundColor: Colors.black87,
|
backgroundColor: Colors.black87,
|
||||||
duration: const Duration(seconds: 10),
|
duration: const Duration(seconds: 10),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:qpay/models/responsive_policy.dart';
|
|||||||
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
|
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
|
||||||
import 'package:qpay/screens/home/home_controller.dart';
|
import 'package:qpay/screens/home/home_controller.dart';
|
||||||
import 'package:qpay/screens/pay/pay_controller.dart';
|
import 'package:qpay/screens/pay/pay_controller.dart';
|
||||||
|
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
class BatchCreateScreen extends StatefulWidget {
|
class BatchCreateScreen extends StatefulWidget {
|
||||||
@@ -57,17 +58,13 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
|||||||
final selectedProvider = controller.model.selectedProvider;
|
final selectedProvider = controller.model.selectedProvider;
|
||||||
|
|
||||||
if (selectedProvider == null) {
|
if (selectedProvider == null) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppSnackBar.show(context, 'Please select a bill provider.');
|
||||||
const SnackBar(content: Text('Please select a bill provider.')),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final amount = double.tryParse(_amountController.text.trim());
|
final amount = double.tryParse(_amountController.text.trim());
|
||||||
if (amount == null || amount <= 0) {
|
if (amount == null || amount <= 0) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppSnackBar.show(context, 'Please enter a valid amount.');
|
||||||
const SnackBar(content: Text('Please enter a valid amount.')),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,8 +103,9 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
|||||||
final batch = result.data!;
|
final batch = result.data!;
|
||||||
context.pushReplacement('/groups/${widget.group.id}/batches/${batch.id}');
|
context.pushReplacement('/groups/${widget.group.id}/batches/${batch.id}');
|
||||||
} else {
|
} else {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppSnackBar.showError(
|
||||||
SnackBar(content: Text(result.error ?? 'Failed to create batch.')),
|
context,
|
||||||
|
result.error ?? 'Failed to create batch.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -441,38 +439,4 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildProcessorDropdown() {
|
|
||||||
if (controller.model.processors.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 processors available',
|
|
||||||
style: TextStyle(color: Colors.grey.shade600),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return DropdownButtonFormField<PaymentProcessor>(
|
|
||||||
value: controller.model.selectedProcessor,
|
|
||||||
isExpanded: true,
|
|
||||||
decoration: InputDecoration(
|
|
||||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
|
||||||
),
|
|
||||||
items: controller.model.processors
|
|
||||||
.map(
|
|
||||||
(p) => DropdownMenuItem(
|
|
||||||
value: p,
|
|
||||||
child: Text(p.name, overflow: TextOverflow.ellipsis),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
onChanged: (p) {
|
|
||||||
if (p != null) controller.selectProcessor(p);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'package:qpay/screens/groups/models/group_batch_model.dart';
|
|||||||
import 'package:qpay/models/responsive_policy.dart';
|
import 'package:qpay/models/responsive_policy.dart';
|
||||||
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
|
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
|
||||||
import 'package:qpay/screens/pay/pay_controller.dart';
|
import 'package:qpay/screens/pay/pay_controller.dart';
|
||||||
|
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||||
import 'package:qpay/widgets/status_chip_widget.dart';
|
import 'package:qpay/widgets/status_chip_widget.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
@@ -147,11 +148,10 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
final batch = _batch;
|
final batch = _batch;
|
||||||
if (batch == null) return;
|
if (batch == null) return;
|
||||||
|
|
||||||
final processor = _selectedProcessor;
|
// Show the bottom sheet so the user can pick a processor and confirm.
|
||||||
if (processor == null) {
|
final processor = await _showProcessorSelectorBottomSheet();
|
||||||
_showError('Please select a payment processor first.');
|
if (processor == null) return; // user dismissed
|
||||||
return;
|
_selectedProcessor = processor;
|
||||||
}
|
|
||||||
|
|
||||||
// Persist the selected processor on the batch before confirming
|
// Persist the selected processor on the batch before confirming
|
||||||
final updateReq = GroupBatchUpdateRequest(
|
final updateReq = GroupBatchUpdateRequest(
|
||||||
@@ -162,10 +162,9 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
paymentProcessorImage: processor.image,
|
paymentProcessorImage: processor.image,
|
||||||
);
|
);
|
||||||
final updateResult = await controller.updateBatch(updateReq);
|
final updateResult = await controller.updateBatch(updateReq);
|
||||||
|
if (!mounted) return;
|
||||||
if (!updateResult.isSuccess) {
|
if (!updateResult.isSuccess) {
|
||||||
if (mounted) {
|
|
||||||
_showError(updateResult.error ?? 'Failed to update processor.');
|
_showError(updateResult.error ?? 'Failed to update processor.');
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +176,271 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
setState(() => _selectedProcessor = null);
|
setState(() => _selectedProcessor = null);
|
||||||
setState(() => _initialLoading = true);
|
setState(() => _initialLoading = true);
|
||||||
await _init();
|
await _init();
|
||||||
|
|
||||||
|
if (!mounted) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppSnackBar.show(
|
||||||
|
context,
|
||||||
|
'Batch items are now being confirmed. Tap refresh to see latest status.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows a bottom sheet that lets the user pick a payment processor.
|
||||||
|
/// Returns the chosen [PaymentProcessor], or `null` if dismissed.
|
||||||
|
Future<PaymentProcessor?> _showProcessorSelectorBottomSheet() async {
|
||||||
|
final processors = controller.model.processors;
|
||||||
|
if (processors.isEmpty) {
|
||||||
|
_showError('No payment processors available.');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isDark = theme.brightness == Brightness.dark;
|
||||||
|
final currency = _batch?.currency ?? 'USD';
|
||||||
|
final value = _batch?.value?.toStringAsFixed(2) ?? '—';
|
||||||
|
|
||||||
|
PaymentProcessor? picked = _selectedProcessor;
|
||||||
|
|
||||||
|
return showModalBottomSheet<PaymentProcessor>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (sheetContext) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (innerContext, setSheetState) {
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isDark ? const Color(0xFF1A1A2E) : Colors.white,
|
||||||
|
borderRadius: const BorderRadius.vertical(
|
||||||
|
top: Radius.circular(24),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
top: 12,
|
||||||
|
bottom: MediaQuery.of(innerContext).viewInsets.bottom + 24,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'Confirm Batch',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: isDark ? Colors.white : Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'Select a payment processor to confirm this batch.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 10,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.primary.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.receipt_long_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
'Batch Total',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
Text(
|
||||||
|
'$currency $value',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
Text(
|
||||||
|
'Payment Processor',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
maxHeight: MediaQuery.of(innerContext).size.height * 0.4,
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Column(
|
||||||
|
children: processors.map((processor) {
|
||||||
|
final isSelected = picked == processor;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
setSheetState(() {
|
||||||
|
picked = processor;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? theme.colorScheme.primary.withValues(
|
||||||
|
alpha: 0.12,
|
||||||
|
)
|
||||||
|
: (isDark
|
||||||
|
? Colors.white.withValues(
|
||||||
|
alpha: 0.05,
|
||||||
|
)
|
||||||
|
: Colors.grey.shade50),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: isSelected
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: (isDark
|
||||||
|
? Colors.white.withValues(
|
||||||
|
alpha: 0.08,
|
||||||
|
)
|
||||||
|
: Colors.grey.shade200),
|
||||||
|
width: isSelected ? 2 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/${processor.image}',
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
errorBuilder: (_, __, ___) => Icon(
|
||||||
|
Icons.payment_rounded,
|
||||||
|
size: 28,
|
||||||
|
color: theme.colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
processor.name,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: isDark
|
||||||
|
? Colors.white
|
||||||
|
: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (processor.label.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
processor.label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
isSelected
|
||||||
|
? Icons.radio_button_checked
|
||||||
|
: Icons.radio_button_unchecked,
|
||||||
|
color: isSelected
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
final selected = picked ?? processors.first;
|
||||||
|
Navigator.of(innerContext).pop(selected);
|
||||||
|
},
|
||||||
|
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(
|
||||||
|
'Confirm',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onRequest() async {
|
Future<void> _onRequest() async {
|
||||||
@@ -186,6 +449,8 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
final result = await controller.requestBatch(batch.id!, _userId);
|
final result = await controller.requestBatch(batch.id!, _userId);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
|
await _init(); // refresh batch and items after request
|
||||||
|
|
||||||
if (!result.isSuccess) {
|
if (!result.isSuccess) {
|
||||||
_showError(result.error ?? 'Request failed.');
|
_showError(result.error ?? 'Request failed.');
|
||||||
return;
|
return;
|
||||||
@@ -195,12 +460,10 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
final pollingStatus = txn.pollingStatus?.toUpperCase();
|
final pollingStatus = txn.pollingStatus?.toUpperCase();
|
||||||
|
|
||||||
if (pollingStatus != null && _terminalStatuses.contains(pollingStatus)) {
|
if (pollingStatus != null && _terminalStatuses.contains(pollingStatus)) {
|
||||||
await controller.listBatchItems(batch.id!);
|
await _init(); // refresh batch and items after request
|
||||||
if (_successStatuses.contains(pollingStatus)) {
|
if (_successStatuses.contains(pollingStatus)) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppSnackBar.showSuccess(context, 'Payment completed successfully.');
|
||||||
const SnackBar(content: Text('Payment completed successfully.')),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -208,11 +471,12 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (pollResult.isSuccess) {
|
if (pollResult.isSuccess) {
|
||||||
setState(() => _showPollButton = false);
|
setState(() => _showPollButton = false);
|
||||||
await controller.listBatchItems(batch.id!);
|
|
||||||
} else {
|
} else {
|
||||||
setState(() => _showPollButton = true);
|
setState(() => _showPollButton = true);
|
||||||
_showError('Auto-poll failed. You can retry manually.');
|
_showError('Auto-poll failed. You can retry manually.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await _init(); // refresh batch and items after request
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -223,7 +487,7 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
setState(() => _showPollButton = false);
|
setState(() => _showPollButton = false);
|
||||||
await controller.listBatchItems(batch.id!);
|
await _init(); // refresh batch and items after poll
|
||||||
} else {
|
} else {
|
||||||
_showError(result.error ?? 'Poll failed.');
|
_showError(result.error ?? 'Poll failed.');
|
||||||
}
|
}
|
||||||
@@ -251,10 +515,16 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _onRefresh() async {
|
||||||
|
setState(() {
|
||||||
|
_initialLoading = true;
|
||||||
|
_loadError = null;
|
||||||
|
});
|
||||||
|
await _init();
|
||||||
|
}
|
||||||
|
|
||||||
void _showError(String message) {
|
void _showError(String message) {
|
||||||
ScaffoldMessenger.of(
|
AppSnackBar.showError(context, message);
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text(message)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
@@ -376,17 +646,6 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
child: _buildBatchHeader(batch, theme, isDark),
|
child: _buildBatchHeader(batch, theme, isDark),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (batch.status?.toUpperCase() == 'CREATED' &&
|
|
||||||
controller.model.processors.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
FadeTransition(
|
|
||||||
opacity: _cardFadeAnimation,
|
|
||||||
child: SlideTransition(
|
|
||||||
position: _cardSlideAnimation,
|
|
||||||
child: _buildProcessorSelector(batch, theme),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
FadeTransition(
|
FadeTransition(
|
||||||
opacity: _cardFadeAnimation,
|
opacity: _cardFadeAnimation,
|
||||||
@@ -478,6 +737,43 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// Refresh Button (sits next to status pill; reloads the batch)
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
Widget _buildRefreshButton(ThemeData theme, bool isDark) {
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
child: InkWell(
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
onTap: _onRefresh,
|
||||||
|
child: Tooltip(
|
||||||
|
message: 'Refresh',
|
||||||
|
child: Container(
|
||||||
|
width: 32,
|
||||||
|
height: 32,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: isDark
|
||||||
|
? Colors.white.withValues(alpha: 0.08)
|
||||||
|
: Colors.grey.shade100,
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark
|
||||||
|
? Colors.white.withValues(alpha: 0.10)
|
||||||
|
: Colors.grey.shade300,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.refresh_rounded,
|
||||||
|
size: 16,
|
||||||
|
color: isDark ? Colors.white70 : Colors.grey.shade700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
// Dotted Divider
|
// Dotted Divider
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
@@ -505,7 +801,6 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
final currency = batch.currency ?? 'USD';
|
final currency = batch.currency ?? 'USD';
|
||||||
final value = batch.value;
|
final value = batch.value;
|
||||||
final status = batch.status ?? 'UNKNOWN';
|
final status = batch.status ?? 'UNKNOWN';
|
||||||
final processorName = batch.paymentProcessorName ?? '';
|
|
||||||
final createdAt = batch.createdAt;
|
final createdAt = batch.createdAt;
|
||||||
final parsedDate = createdAt != null ? DateTime.tryParse(createdAt) : null;
|
final parsedDate = createdAt != null ? DateTime.tryParse(createdAt) : null;
|
||||||
|
|
||||||
@@ -537,8 +832,15 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20),
|
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
_buildStatusBadge(status),
|
_buildStatusBadge(status),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildRefreshButton(theme, isDark),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@@ -564,24 +866,6 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (processorName.isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: primaryColor.withValues(alpha: 0.08),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
processorName,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: primaryColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
if (parsedDate != null)
|
if (parsedDate != null)
|
||||||
Text(
|
Text(
|
||||||
@@ -600,100 +884,6 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────
|
|
||||||
// Processor Selection Row
|
|
||||||
// ──────────────────────────────────────────────────────────────
|
|
||||||
Widget _buildProcessorSelector(GroupBatch batch, ThemeData theme) {
|
|
||||||
final processors = controller.model.processors;
|
|
||||||
if (processors.isEmpty) return const SizedBox.shrink();
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'Select payment processor to proceed',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Colors.grey.shade500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
SizedBox(
|
|
||||||
height: 80,
|
|
||||||
child: ListView.separated(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
itemCount: processors.length,
|
|
||||||
separatorBuilder: (_, __) => const SizedBox(width: 10),
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final processor = processors[index];
|
|
||||||
final isSelected = _selectedProcessor == processor;
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
setState(() => _selectedProcessor = processor);
|
|
||||||
},
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 250),
|
|
||||||
curve: Curves.easeOutCubic,
|
|
||||||
width: 140,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isSelected
|
|
||||||
? theme.colorScheme.primary.withValues(alpha: 0.12)
|
|
||||||
: (theme.brightness == Brightness.dark
|
|
||||||
? Colors.white.withValues(alpha: 0.06)
|
|
||||||
: Colors.white),
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
border: Border.all(
|
|
||||||
color: isSelected
|
|
||||||
? theme.colorScheme.primary
|
|
||||||
: (theme.brightness == Brightness.dark
|
|
||||||
? Colors.white.withValues(alpha: 0.08)
|
|
||||||
: Colors.grey.shade200),
|
|
||||||
width: isSelected ? 2 : 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: Image.asset(
|
|
||||||
'assets/${processor.image}',
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
errorBuilder: (_, __, ___) => Icon(
|
|
||||||
Icons.payment_rounded,
|
|
||||||
size: 28,
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
Text(
|
|
||||||
processor.name,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: isSelected
|
|
||||||
? theme.colorScheme.primary
|
|
||||||
: (theme.brightness == Brightness.dark
|
|
||||||
? Colors.white70
|
|
||||||
: Colors.black87),
|
|
||||||
),
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
// Header Action Buttons
|
// Header Action Buttons
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
@@ -707,19 +897,17 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
|||||||
buttons.add(
|
buttons.add(
|
||||||
_modernActionButton(
|
_modernActionButton(
|
||||||
icon: Icons.check_circle_outline_rounded,
|
icon: Icons.check_circle_outline_rounded,
|
||||||
label: 'Confirm',
|
label: 'Confirm Items',
|
||||||
color: _selectedProcessor != null
|
color: theme.colorScheme.primary,
|
||||||
? theme.colorScheme.primary
|
|
||||||
: Colors.grey,
|
|
||||||
isLoading: isActing,
|
isLoading: isActing,
|
||||||
onPressed: _onConfirm,
|
onPressed: _onConfirm,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (status == 'CONFIRMED_ALL') {
|
} else if (status == 'CONFIRMED_ALL' || status == 'REQUEST_FAILED') {
|
||||||
buttons.add(
|
buttons.add(
|
||||||
_modernActionButton(
|
_modernActionButton(
|
||||||
icon: Icons.send_rounded,
|
icon: Icons.send_rounded,
|
||||||
label: 'Request',
|
label: 'Push Payment Request',
|
||||||
color: const Color(0xFF10B981),
|
color: const Color(0xFF10B981),
|
||||||
isLoading: isActing,
|
isLoading: isActing,
|
||||||
onPressed: _onRequest,
|
onPressed: _onRequest,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
|
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
|
||||||
import 'package:qpay/models/responsive_policy.dart';
|
import 'package:qpay/models/responsive_policy.dart';
|
||||||
import 'package:qpay/screens/groups/controllers/group_controller.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 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import '../recipient/recipients_controller.dart';
|
import '../recipient/recipients_controller.dart';
|
||||||
@@ -68,12 +69,9 @@ class _GroupCreateScreenState extends State<GroupCreateScreen> {
|
|||||||
|
|
||||||
final recipients = _parseRecipients(_recipientsController.text);
|
final recipients = _parseRecipients(_recipientsController.text);
|
||||||
if (recipients.isEmpty) {
|
if (recipients.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppSnackBar.show(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text(
|
|
||||||
'Please add at least one recipient in name,account format.',
|
'Please add at least one recipient in name,account format.',
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -94,13 +92,12 @@ class _GroupCreateScreenState extends State<GroupCreateScreen> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppSnackBar.showSuccess(context, 'Group created successfully.');
|
||||||
const SnackBar(content: Text('Group created successfully.')),
|
|
||||||
);
|
|
||||||
context.pop(true); // Return true to indicate a new group was created
|
context.pop(true); // Return true to indicate a new group was created
|
||||||
} else {
|
} else {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppSnackBar.showError(
|
||||||
SnackBar(content: Text(result.error ?? 'Failed to create group.')),
|
context,
|
||||||
|
result.error ?? 'Failed to create group.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import 'package:qpay/screens/groups/models/recipient_group_model.dart';
|
|||||||
import 'package:qpay/models/responsive_policy.dart';
|
import 'package:qpay/models/responsive_policy.dart';
|
||||||
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
|
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
|
||||||
import 'package:qpay/screens/groups/controllers/group_controller.dart';
|
import 'package:qpay/screens/groups/controllers/group_controller.dart';
|
||||||
|
import 'package:qpay/screens/recipient/recipients_controller.dart';
|
||||||
import 'package:qpay/widgets/status_chip_widget.dart';
|
import 'package:qpay/widgets/status_chip_widget.dart';
|
||||||
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
|
|
||||||
class GroupDetailScreen extends StatefulWidget {
|
class GroupDetailScreen extends StatefulWidget {
|
||||||
final RecipientGroup group;
|
final RecipientGroup group;
|
||||||
@@ -16,11 +18,15 @@ class GroupDetailScreen extends StatefulWidget {
|
|||||||
State<GroupDetailScreen> createState() => _GroupDetailScreenState();
|
State<GroupDetailScreen> createState() => _GroupDetailScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
late GroupController groupController;
|
late GroupController groupController;
|
||||||
late BatchController batchController;
|
late BatchController batchController;
|
||||||
final _searchController = TextEditingController();
|
final _searchController = TextEditingController();
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
|
final _memberSearchController = TextEditingController();
|
||||||
|
late final AnimationController _membersAnimController;
|
||||||
|
String _memberQuery = '';
|
||||||
String? _statusFilter;
|
String? _statusFilter;
|
||||||
String? _currencyFilter;
|
String? _currencyFilter;
|
||||||
|
|
||||||
@@ -50,6 +56,10 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
|||||||
_loadData();
|
_loadData();
|
||||||
_scrollController.addListener(_onScroll);
|
_scrollController.addListener(_onScroll);
|
||||||
_searchController.addListener(() => setState(() {}));
|
_searchController.addListener(() => setState(() {}));
|
||||||
|
_membersAnimController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 600),
|
||||||
|
)..forward();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
@@ -77,55 +87,585 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showMembersSheet() {
|
void _showMembersSheet() {
|
||||||
|
_memberSearchController.clear();
|
||||||
|
_memberQuery = '';
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||||
),
|
),
|
||||||
builder: (ctx) => DraggableScrollableSheet(
|
builder: (ctx) {
|
||||||
initialChildSize: 0.6,
|
return DraggableScrollableSheet(
|
||||||
minChildSize: 0.3,
|
initialChildSize: 0.7,
|
||||||
maxChildSize: 0.85,
|
minChildSize: 0.4,
|
||||||
|
maxChildSize: 0.92,
|
||||||
expand: false,
|
expand: false,
|
||||||
builder: (context, scrollController) {
|
builder: (context, scrollController) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setSheetState) {
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_buildMembersSheetHandle(),
|
||||||
|
_buildMembersSheetHeader(
|
||||||
|
setSheetState,
|
||||||
|
scrollController,
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: ListenableBuilder(
|
||||||
|
listenable: groupController,
|
||||||
|
builder: (context, _) {
|
||||||
|
final allMembers = groupController.model.members;
|
||||||
|
final filtered = _filteredMembers(allMembers);
|
||||||
|
if (groupController.model.isLoading) {
|
||||||
|
return _buildMembersSkeletonList(
|
||||||
|
scrollController,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (allMembers.isEmpty) {
|
||||||
|
return _buildMembersEmptyState();
|
||||||
|
}
|
||||||
|
if (filtered.isEmpty) {
|
||||||
|
return _buildNoSearchResultsState();
|
||||||
|
}
|
||||||
|
return ListView.separated(
|
||||||
|
controller: scrollController,
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||||
|
itemCount: filtered.length,
|
||||||
|
separatorBuilder: (_, __) =>
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return _buildAnimatedMemberCard(
|
||||||
|
index,
|
||||||
|
filtered[index],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMembersSheetHandle() {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.only(top: 10, bottom: 6),
|
||||||
|
child: Container(
|
||||||
|
width: 44,
|
||||||
|
height: 5,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMembersSheetHeader(
|
||||||
|
StateSetter setSheetState,
|
||||||
|
ScrollController scrollController,
|
||||||
|
) {
|
||||||
|
final total = groupController.model.members.length;
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 4, 12, 8),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary
|
||||||
|
.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.people_alt_outlined,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
'Members',
|
'Members',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'$total ${total == 1 ? "member" : "members"} in this group',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.close),
|
icon: Icon(Icons.close, color: Colors.grey.shade700),
|
||||||
|
tooltip: 'Close',
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
if (total > 0) ...[
|
||||||
Text(
|
const SizedBox(height: 14),
|
||||||
'${groupController.model.members.length} member(s)',
|
TextFormField(
|
||||||
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
|
controller: _memberSearchController,
|
||||||
|
onChanged: (v) {
|
||||||
|
setSheetState(() => _memberQuery = v);
|
||||||
|
},
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: 'Search members...',
|
||||||
|
hintStyle: TextStyle(
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
prefixIcon: Icon(
|
||||||
const Divider(),
|
Icons.search,
|
||||||
Expanded(
|
color: Colors.grey.shade600,
|
||||||
child: ListenableBuilder(
|
size: 20,
|
||||||
listenable: groupController,
|
),
|
||||||
builder: (context, _) {
|
suffixIcon: _memberQuery.isNotEmpty
|
||||||
if (groupController.model.isLoading) {
|
? IconButton(
|
||||||
return const Center(child: CircularProgressIndicator());
|
icon: Icon(
|
||||||
|
Icons.clear,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
_memberSearchController.clear();
|
||||||
|
setSheetState(() => _memberQuery = '');
|
||||||
|
},
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
isDense: true,
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (groupController.model.members.isEmpty) {
|
List<RecipientGroupMember> _filteredMembers(
|
||||||
|
List<RecipientGroupMember> members,
|
||||||
|
) {
|
||||||
|
if (_memberQuery.trim().isEmpty) return members;
|
||||||
|
final q = _memberQuery.toLowerCase().trim();
|
||||||
|
return members.where((m) {
|
||||||
|
final r = m.recipient;
|
||||||
|
return (r.name?.toLowerCase().contains(q) ?? false) ||
|
||||||
|
(r.account?.toLowerCase().contains(q) ?? false) ||
|
||||||
|
(r.email?.toLowerCase().contains(q) ?? false) ||
|
||||||
|
(r.phoneNumber?.toLowerCase().contains(q) ?? false) ||
|
||||||
|
(r.latestProviderLabel?.toLowerCase().contains(q) ?? false);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildAnimatedMemberCard(int index, RecipientGroupMember member) {
|
||||||
|
final clampedIndex = index.clamp(0, 5);
|
||||||
|
final animation = Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.06),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(
|
||||||
|
CurvedAnimation(
|
||||||
|
parent: _membersAnimController,
|
||||||
|
curve: Interval(
|
||||||
|
0.05 * clampedIndex,
|
||||||
|
1.0,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: _membersAnimController,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: animation,
|
||||||
|
child: _buildMemberCard(member),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMemberCard(RecipientGroupMember member) {
|
||||||
|
final recipient = member.recipient;
|
||||||
|
final name = recipient.name?.isNotEmpty == true
|
||||||
|
? recipient.name!
|
||||||
|
: 'Unnamed recipient';
|
||||||
|
final initials = _initialsFor(name);
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: Colors.grey.shade200, width: 1),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.04),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 14,
|
||||||
|
horizontal: 14,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
_buildMemberAvatar(initials),
|
||||||
|
const SizedBox(width: 14),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
name,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildMemberSubtitleRow(recipient, member.account),
|
||||||
|
if (recipient.phoneNumber != null &&
|
||||||
|
recipient.phoneNumber!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
_buildContactLine(
|
||||||
|
Icons.phone_outlined,
|
||||||
|
recipient.phoneNumber!,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (recipient.email != null &&
|
||||||
|
recipient.email!.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
_buildContactLine(
|
||||||
|
Icons.email_outlined,
|
||||||
|
recipient.email!,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_buildMemberPopupMenu(member),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMemberAvatar(String initials) {
|
||||||
|
return Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Theme.of(context).colorScheme.primary,
|
||||||
|
Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary
|
||||||
|
.withValues(alpha: 0.7),
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary
|
||||||
|
.withValues(alpha: 0.25),
|
||||||
|
blurRadius: 6,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: Text(
|
||||||
|
initials,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMemberSubtitleRow(
|
||||||
|
Recipient recipient,
|
||||||
|
String? memberAccount,
|
||||||
|
) {
|
||||||
|
final account = memberAccount?.isNotEmpty == true
|
||||||
|
? memberAccount
|
||||||
|
: recipient.account;
|
||||||
|
final hasAccount = account != null && account.isNotEmpty;
|
||||||
|
final hasProvider = recipient.latestProviderLabel != null &&
|
||||||
|
recipient.latestProviderLabel!.isNotEmpty;
|
||||||
|
if (!hasAccount && !hasProvider) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return Wrap(
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: [
|
||||||
|
if (hasProvider)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary
|
||||||
|
.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
recipient.latestProviderLabel!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (hasAccount)
|
||||||
|
Text(
|
||||||
|
account!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildContactLine(IconData icon, String text) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 13, color: Colors.grey.shade500),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMemberPopupMenu(RecipientGroupMember member) {
|
||||||
|
return PopupMenuButton<String>(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.more_vert,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
splashRadius: 20,
|
||||||
|
tooltip: 'More options',
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
onSelected: (value) async {
|
||||||
|
if (value == 'remove') {
|
||||||
|
await _confirmRemoveMember(member);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'remove',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.person_remove_outlined,
|
||||||
|
size: 18, color: Colors.red),
|
||||||
|
SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
'Remove from group',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.red,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmRemoveMember(RecipientGroupMember member) async {
|
||||||
|
final name = member.recipient.name ?? 'this member';
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
title: const Text('Remove member'),
|
||||||
|
content: Text('Are you sure you want to remove $name from this group?'),
|
||||||
|
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('Remove'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed == true && mounted) {
|
||||||
|
await groupController.removeMember(
|
||||||
|
groupId: widget.group.id ?? '',
|
||||||
|
recipientId: member.id ?? '',
|
||||||
|
userId: widget.group.userId ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMembersSkeletonList(ScrollController scrollController) {
|
||||||
|
return ListView.separated(
|
||||||
|
controller: scrollController,
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||||
|
itemCount: 6,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return Skeletonizer(
|
||||||
|
enabled: true,
|
||||||
|
child: _buildMemberCard(
|
||||||
|
RecipientGroupMember(
|
||||||
|
recipientGroupId: '',
|
||||||
|
recipient: Recipient(
|
||||||
|
name: 'Loading Name Here',
|
||||||
|
account: '0000000000',
|
||||||
|
phoneNumber: '+000 000 0000',
|
||||||
|
email: 'loading@example.com',
|
||||||
|
latestProviderLabel: 'PROVIDER',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMembersEmptyState() {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 88,
|
||||||
|
height: 88,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.primary
|
||||||
|
.withValues(alpha: 0.08),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.people_alt_outlined,
|
||||||
|
size: 44,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
const Text(
|
||||||
|
'No members yet',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Members added to this group will appear\nhere once they are available.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildNoSearchResultsState() {
|
||||||
return Center(
|
return Center(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
@@ -133,16 +673,26 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.person_outline,
|
Icons.search_off_outlined,
|
||||||
size: 48,
|
size: 56,
|
||||||
color: Colors.grey.shade400,
|
color: Colors.grey.shade400,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Text(
|
const Text(
|
||||||
'No members found.',
|
'No matching members',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'No results for "$_memberQuery"',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -151,64 +701,15 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView.separated(
|
String _initialsFor(String name) {
|
||||||
controller: scrollController,
|
final cleaned = name.trim();
|
||||||
itemCount: groupController.model.members.length,
|
if (cleaned.isEmpty) return '?';
|
||||||
separatorBuilder: (_, __) => const Divider(height: 1),
|
final parts = cleaned.split(RegExp(r'\s+'));
|
||||||
itemBuilder: (context, index) {
|
if (parts.length == 1) {
|
||||||
final member = groupController.model.members[index];
|
return parts.first.characters.first.toUpperCase();
|
||||||
return ListTile(
|
}
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
return (parts.first.characters.first + parts.last.characters.first)
|
||||||
vertical: 2,
|
.toUpperCase();
|
||||||
horizontal: 4,
|
|
||||||
),
|
|
||||||
leading: CircleAvatar(
|
|
||||||
backgroundColor: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.primary.withAlpha(30),
|
|
||||||
child: Text(
|
|
||||||
member.recipient.name?.isNotEmpty == true
|
|
||||||
? member.recipient.name![0].toUpperCase()
|
|
||||||
: '?',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
member.recipient.name ?? '',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
subtitle: Text(member.recipient.account ?? ''),
|
|
||||||
trailing:
|
|
||||||
member.recipient.latestProviderLabel != null &&
|
|
||||||
member
|
|
||||||
.recipient
|
|
||||||
.latestProviderLabel!
|
|
||||||
.isNotEmpty
|
|
||||||
? Chip(
|
|
||||||
label: Text(
|
|
||||||
member.recipient.latestProviderLabel!,
|
|
||||||
style: const TextStyle(fontSize: 11),
|
|
||||||
),
|
|
||||||
visualDensity: VisualDensity.compact,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onDeleteSelected() async {
|
void _onDeleteSelected() async {
|
||||||
@@ -352,6 +853,8 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
|||||||
void dispose() {
|
void dispose() {
|
||||||
_searchController.dispose();
|
_searchController.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
|
_memberSearchController.dispose();
|
||||||
|
_membersAnimController.dispose();
|
||||||
groupController.dispose();
|
groupController.dispose();
|
||||||
batchController.dispose();
|
batchController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
@@ -468,6 +971,13 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
|||||||
onPressed: () => batchController.toggleSelectMode(),
|
onPressed: () => batchController.toggleSelectMode(),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
_buildIconButton(
|
||||||
|
icon: Icons.refresh,
|
||||||
|
tooltip: 'Refresh batches',
|
||||||
|
isActive: false,
|
||||||
|
onPressed: _loadData,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
_buildIconButton(
|
_buildIconButton(
|
||||||
icon: Icons.add,
|
icon: Icons.add,
|
||||||
tooltip: 'Create batch',
|
tooltip: 'Create batch',
|
||||||
|
|||||||
@@ -179,6 +179,13 @@ class _GroupsScreenState extends State<GroupsScreen> {
|
|||||||
onPressed: () => controller.toggleSelectMode(),
|
onPressed: () => controller.toggleSelectMode(),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
_buildIconButton(
|
||||||
|
icon: Icons.refresh,
|
||||||
|
tooltip: 'Refresh groups',
|
||||||
|
isActive: false,
|
||||||
|
onPressed: _loadData,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
_buildIconButton(
|
_buildIconButton(
|
||||||
icon: Icons.add,
|
icon: Icons.add,
|
||||||
tooltip: 'Create new group',
|
tooltip: 'Create new group',
|
||||||
|
|||||||
@@ -8,54 +8,37 @@ class HistoryModel {
|
|||||||
|
|
||||||
List<Map<String, dynamic>> transactions = [];
|
List<Map<String, dynamic>> transactions = [];
|
||||||
bool isLoading = false;
|
bool isLoading = false;
|
||||||
|
bool isLoadingMore = false;
|
||||||
|
bool isActing = false;
|
||||||
String? errorMessage;
|
String? errorMessage;
|
||||||
|
|
||||||
|
int currentPage = 0;
|
||||||
|
int totalPages = 0;
|
||||||
|
|
||||||
|
String searchQuery = '';
|
||||||
|
String? statusFilter;
|
||||||
|
String? typeFilter;
|
||||||
|
|
||||||
|
bool selectMode = false;
|
||||||
|
Set<String> selectedTransactionIds = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
class HistoryController extends ChangeNotifier {
|
class HistoryController extends ChangeNotifier {
|
||||||
final HistoryModel model = HistoryModel();
|
final HistoryModel model = HistoryModel();
|
||||||
final Http http = Http();
|
final Http http = Http();
|
||||||
BuildContext context;
|
BuildContext context;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
HistoryController(this.context);
|
HistoryController(this.context);
|
||||||
|
|
||||||
Future<ApiResponse<List<Map<String, dynamic>>>> getTransactions(Map<String, String> params) async {
|
@override
|
||||||
model.isLoading = true;
|
void dispose() {
|
||||||
if (params['page'] == '0') {
|
_disposed = true;
|
||||||
model.transactions = getFakeTransactions();
|
super.dispose();
|
||||||
}
|
|
||||||
notifyListeners();
|
|
||||||
|
|
||||||
try {
|
|
||||||
Map<String, dynamic> response = await http.get(
|
|
||||||
'/public/transaction?${buildQueryParameters(params)}',
|
|
||||||
);
|
|
||||||
|
|
||||||
if (params['page'] == '0') {
|
|
||||||
model.transactions = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
model.pageableModel = PageableModel.fromJson(response);
|
void _safeNotify() {
|
||||||
|
if (!_disposed) notifyListeners();
|
||||||
// pagination adds to current list instead of replacing it
|
|
||||||
model.transactions =
|
|
||||||
model.transactions +
|
|
||||||
model.pageableModel!.content
|
|
||||||
.map((e) => e as Map<String, dynamic>)
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
model.isLoading = false;
|
|
||||||
notifyListeners();
|
|
||||||
return ApiResponse.success(List<Map<String, dynamic>>.from(model.transactions));
|
|
||||||
} catch (e) {
|
|
||||||
logger.e(e);
|
|
||||||
model.errorMessage = e.toString();
|
|
||||||
model.transactions = [];
|
|
||||||
model.isLoading = false;
|
|
||||||
notifyListeners();
|
|
||||||
return ApiResponse.failure(
|
|
||||||
"Problem fetching transactions, are you connected to the internet?",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
String buildQueryParameters(Map<String, String> params) {
|
String buildQueryParameters(Map<String, String> params) {
|
||||||
@@ -70,6 +53,152 @@ class HistoryController extends ChangeNotifier {
|
|||||||
return query.substring(0, query.length - 1);
|
return query.substring(0, query.length - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse<List<Map<String, dynamic>>>> getTransactions(
|
||||||
|
Map<String, String> params,
|
||||||
|
) async {
|
||||||
|
final isFirstPage = params['page'] == '0' || params['page'] == null;
|
||||||
|
if (isFirstPage) {
|
||||||
|
model.isLoading = true;
|
||||||
|
model.transactions = [];
|
||||||
|
model.currentPage = 0;
|
||||||
|
} else {
|
||||||
|
model.isLoadingMore = true;
|
||||||
|
}
|
||||||
|
_safeNotify();
|
||||||
|
|
||||||
|
params['partyType'] = 'INDIVIDUAL';
|
||||||
|
|
||||||
|
try {
|
||||||
|
Map<String, dynamic> response = await http.get(
|
||||||
|
'/public/transaction?${buildQueryParameters(params)}',
|
||||||
|
);
|
||||||
|
|
||||||
|
model.pageableModel = PageableModel.fromJson(response);
|
||||||
|
model.currentPage = model.pageableModel!.number;
|
||||||
|
model.totalPages = model.pageableModel!.totalPages;
|
||||||
|
|
||||||
|
final newItems = model.pageableModel!.content
|
||||||
|
.map((e) => Map<String, dynamic>.from(e as Map))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (isFirstPage) {
|
||||||
|
model.transactions = newItems;
|
||||||
|
} else {
|
||||||
|
model.transactions.addAll(newItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
model.isLoading = false;
|
||||||
|
model.isLoadingMore = false;
|
||||||
|
_safeNotify();
|
||||||
|
return ApiResponse.success(model.transactions);
|
||||||
|
} catch (e) {
|
||||||
|
logger.e(e);
|
||||||
|
model.errorMessage = e.toString();
|
||||||
|
if (isFirstPage) {
|
||||||
|
model.transactions = [];
|
||||||
|
}
|
||||||
|
model.isLoading = false;
|
||||||
|
model.isLoadingMore = false;
|
||||||
|
_safeNotify();
|
||||||
|
return ApiResponse.failure(
|
||||||
|
"Problem fetching transactions, are you connected to the internet?",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> searchTransactions(
|
||||||
|
String userId, {
|
||||||
|
String? search,
|
||||||
|
String? status,
|
||||||
|
String? type,
|
||||||
|
}) async {
|
||||||
|
model.searchQuery = search ?? '';
|
||||||
|
model.statusFilter = status;
|
||||||
|
model.typeFilter = type;
|
||||||
|
final params = <String, String>{
|
||||||
|
'userId': userId,
|
||||||
|
'page': '0',
|
||||||
|
'size': '20',
|
||||||
|
'sort': 'createdAt,desc',
|
||||||
|
};
|
||||||
|
if (search != null && search.isNotEmpty) {
|
||||||
|
params['creditAccount'] = search;
|
||||||
|
params['creditEmail'] = search;
|
||||||
|
params['creditPhone'] = search;
|
||||||
|
params['creditName'] = search;
|
||||||
|
params['billName'] = search;
|
||||||
|
params['amount'] = search;
|
||||||
|
}
|
||||||
|
if (status != null && status.isNotEmpty) {
|
||||||
|
params['status'] = status;
|
||||||
|
}
|
||||||
|
if (type != null && type.isNotEmpty) {
|
||||||
|
params['type'] = type;
|
||||||
|
}
|
||||||
|
await getTransactions(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> loadNextPage(String userId) async {
|
||||||
|
if (model.isLoadingMore || model.currentPage + 1 >= model.totalPages) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final params = <String, String>{
|
||||||
|
'userId': userId,
|
||||||
|
'page': (model.currentPage + 1).toString(),
|
||||||
|
'size': '20',
|
||||||
|
'sort': 'createdAt,desc',
|
||||||
|
};
|
||||||
|
if (model.searchQuery.isNotEmpty) {
|
||||||
|
params['creditAccount'] = model.searchQuery;
|
||||||
|
params['creditEmail'] = model.searchQuery;
|
||||||
|
params['creditPhone'] = model.searchQuery;
|
||||||
|
params['creditName'] = model.searchQuery;
|
||||||
|
params['billName'] = model.searchQuery;
|
||||||
|
params['amount'] = model.searchQuery;
|
||||||
|
}
|
||||||
|
if (model.statusFilter != null && model.statusFilter!.isNotEmpty) {
|
||||||
|
params['status'] = model.statusFilter!;
|
||||||
|
}
|
||||||
|
if (model.typeFilter != null && model.typeFilter!.isNotEmpty) {
|
||||||
|
params['type'] = model.typeFilter!;
|
||||||
|
}
|
||||||
|
await getTransactions(params);
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleSelectMode() {
|
||||||
|
model.selectMode = !model.selectMode;
|
||||||
|
if (!model.selectMode) {
|
||||||
|
model.selectedTransactionIds.clear();
|
||||||
|
}
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleTransactionSelection(String id) {
|
||||||
|
if (id.isEmpty) return;
|
||||||
|
if (model.selectedTransactionIds.contains(id)) {
|
||||||
|
model.selectedTransactionIds.remove(id);
|
||||||
|
} else {
|
||||||
|
model.selectedTransactionIds.add(id);
|
||||||
|
}
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes selected transactions from the local list. The history
|
||||||
|
/// endpoint does not support deletes, so this is a client-side action.
|
||||||
|
void deleteSelectedTransactions() {
|
||||||
|
final ids = List<String>.from(model.selectedTransactionIds);
|
||||||
|
model.transactions.removeWhere((t) => ids.contains(t['id'] as String?));
|
||||||
|
model.selectMode = false;
|
||||||
|
model.selectedTransactionIds.clear();
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearFilters() {
|
||||||
|
model.statusFilter = null;
|
||||||
|
model.typeFilter = null;
|
||||||
|
_safeNotify();
|
||||||
|
}
|
||||||
|
|
||||||
List<Map<String, dynamic>> getFakeTransactions() {
|
List<Map<String, dynamic>> getFakeTransactions() {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:qpay/screens/receipt/receipt_controller.dart';
|
|||||||
import 'package:qpay/screens/transaction_controller.dart';
|
import 'package:qpay/screens/transaction_controller.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:qpay/screens/history/history_controller.dart';
|
import 'package:qpay/screens/history/history_controller.dart';
|
||||||
|
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||||
import 'package:qpay/widgets/transaction_item_widget.dart';
|
import 'package:qpay/widgets/transaction_item_widget.dart';
|
||||||
import 'package:qpay/screens/transactions/transaction_model.dart' as txn;
|
import 'package:qpay/screens/transactions/transaction_model.dart' as txn;
|
||||||
|
|
||||||
@@ -20,39 +21,61 @@ class _HistoryScreenState extends State<HistoryScreen>
|
|||||||
with TickerProviderStateMixin {
|
with TickerProviderStateMixin {
|
||||||
late HistoryController controller;
|
late HistoryController controller;
|
||||||
late SharedPreferences prefs;
|
late SharedPreferences prefs;
|
||||||
late AnimationController _controller;
|
|
||||||
late TransactionController transactionController;
|
late TransactionController transactionController;
|
||||||
|
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final Map<String, String> _queryParams = {};
|
final ScrollController _scrollController = ScrollController();
|
||||||
final int _pageSize = 8;
|
late final AnimationController _entryController;
|
||||||
|
late final Animation<double> _entryFade;
|
||||||
|
late final Animation<Offset> _entrySlide;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
controller = HistoryController(context);
|
controller = HistoryController(context);
|
||||||
setupData();
|
|
||||||
|
|
||||||
transactionController = Provider.of<TransactionController>(
|
transactionController = Provider.of<TransactionController>(
|
||||||
context,
|
context,
|
||||||
listen: false,
|
listen: false,
|
||||||
);
|
);
|
||||||
|
|
||||||
_controller = AnimationController(
|
// Short, subtle entry animation (replaces the old 3-second stagger)
|
||||||
duration: const Duration(milliseconds: 3000),
|
_entryController = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
vsync: this,
|
vsync: this,
|
||||||
)..forward();
|
);
|
||||||
|
_entryFade = Tween<double>(
|
||||||
|
begin: 0.0,
|
||||||
|
end: 1.0,
|
||||||
|
).animate(CurvedAnimation(parent: _entryController, curve: Curves.easeOut));
|
||||||
|
_entrySlide = Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.02),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(parent: _entryController, curve: Curves.easeOut));
|
||||||
|
_entryController.forward();
|
||||||
|
|
||||||
|
// Listen to controller changes to rebuild when transactions are loaded
|
||||||
|
controller.addListener(() => setState(() {}));
|
||||||
|
_searchController.addListener(() => setState(() {}));
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
|
||||||
|
setupData();
|
||||||
}
|
}
|
||||||
|
|
||||||
void setupData() async {
|
Future<void> setupData() async {
|
||||||
prefs = await SharedPreferences.getInstance();
|
prefs = await SharedPreferences.getInstance();
|
||||||
|
final userId = prefs.getString("userId") ?? '';
|
||||||
|
await controller.searchTransactions(userId);
|
||||||
|
}
|
||||||
|
|
||||||
await controller.getTransactions({
|
void _onScroll() {
|
||||||
'userId': prefs.getString("userId")!,
|
if (_scrollController.position.pixels >=
|
||||||
'page': '0',
|
_scrollController.position.maxScrollExtent - 200) {
|
||||||
'size': _pageSize.toString(),
|
final userId = prefs.getString("userId") ?? '';
|
||||||
'sort': 'createdAt,desc',
|
if (userId.isNotEmpty) {
|
||||||
});
|
controller.loadNextPage(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handles the repeat action for a transaction.
|
/// Handles the repeat action for a transaction.
|
||||||
@@ -60,10 +83,178 @@ class _HistoryScreenState extends State<HistoryScreen>
|
|||||||
final receiptController = ReceiptController(context);
|
final receiptController = ReceiptController(context);
|
||||||
final transaction = txn.TransactionModel.fromJson(item);
|
final transaction = txn.TransactionModel.fromJson(item);
|
||||||
await receiptController.repeatTransaction(transaction);
|
await receiptController.repeatTransaction(transaction);
|
||||||
|
if (mounted) {
|
||||||
|
context.push('/make-payment');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onDeleteSelected() async {
|
||||||
|
final count = controller.model.selectedTransactionIds.length;
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('Delete transactions'),
|
||||||
|
content: Text('Remove $count transaction(s) from this list?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: const Text('Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed == true) {
|
||||||
|
controller.deleteSelectedTransactions();
|
||||||
|
if (mounted) {
|
||||||
|
AppSnackBar.show(
|
||||||
|
context,
|
||||||
|
'Transactions removed from this list',
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showFilterSheet() {
|
||||||
|
showModalBottomSheet(
|
||||||
|
context: context,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||||
|
),
|
||||||
|
builder: (ctx) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setSheetState) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Filter Transactions',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
setSheetState(() {
|
||||||
|
controller.model.statusFilter = null;
|
||||||
|
controller.model.typeFilter = null;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
child: const Text('Clear All'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<String?>(
|
||||||
|
initialValue: controller.model.statusFilter,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Status',
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: null, child: Text('All')),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'SUCCESS',
|
||||||
|
child: Text('Success'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'PENDING',
|
||||||
|
child: Text('Pending'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(value: 'FAILED', child: Text('Failed')),
|
||||||
|
],
|
||||||
|
onChanged: (v) {
|
||||||
|
setSheetState(() => controller.model.statusFilter = v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
DropdownButtonFormField<String?>(
|
||||||
|
initialValue: controller.model.typeFilter,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: 'Type',
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
items: const [
|
||||||
|
DropdownMenuItem(value: null, child: Text('All')),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'REQUEST',
|
||||||
|
child: Text('Request'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: 'PAYMENT',
|
||||||
|
child: Text('Payment'),
|
||||||
|
),
|
||||||
|
DropdownMenuItem(value: 'REFUND', child: Text('Refund')),
|
||||||
|
],
|
||||||
|
onChanged: (v) {
|
||||||
|
setSheetState(() => controller.model.typeFilter = v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: ElevatedButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
final userId = prefs.getString("userId") ?? '';
|
||||||
|
controller.searchTransactions(
|
||||||
|
userId,
|
||||||
|
search: _searchController.text.isNotEmpty
|
||||||
|
? _searchController.text
|
||||||
|
: null,
|
||||||
|
status: controller.model.statusFilter,
|
||||||
|
type: controller.model.typeFilter,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text('Apply Filters'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_searchController.dispose();
|
||||||
|
_scrollController.dispose();
|
||||||
|
_entryController.dispose();
|
||||||
controller.dispose();
|
controller.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -71,200 +262,419 @@ class _HistoryScreenState extends State<HistoryScreen>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: CustomScrollView(
|
appBar: AppBar(title: const Text('History'), centerTitle: true),
|
||||||
|
body: Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: _scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverAppBar(
|
|
||||||
title: Text(
|
|
||||||
'History',
|
|
||||||
style: TextStyle(fontSize: 20, color: Colors.black),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
|
||||||
),
|
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: ListenableBuilder(
|
child: FadeTransition(
|
||||||
listenable: controller,
|
opacity: _entryFade,
|
||||||
builder: (context, child) {
|
child: SlideTransition(
|
||||||
return Container(
|
position: _entrySlide,
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Center(
|
child: Center(
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
return SizedBox(
|
final width =
|
||||||
width: double.parse(
|
|
||||||
constraints.maxWidth > ResponsivePolicy.md
|
constraints.maxWidth > ResponsivePolicy.md
|
||||||
? ResponsivePolicy.md.toString()
|
? ResponsivePolicy.md.toDouble()
|
||||||
: constraints.maxWidth.toString(),
|
: constraints.maxWidth;
|
||||||
),
|
return SizedBox(
|
||||||
|
width: width,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildSearchField(controller),
|
_buildSearchRow(),
|
||||||
SizedBox(height: 10),
|
const SizedBox(height: 12),
|
||||||
if (controller.model.transactions.isEmpty)
|
_buildContent(),
|
||||||
Column(
|
|
||||||
children: [
|
|
||||||
SizedBox(height: 30),
|
|
||||||
Text(
|
|
||||||
'No transactions yet. Make a payment to see your recent activity.',
|
|
||||||
style: TextStyle(fontSize: 16),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (controller.model.transactions.isNotEmpty)
|
|
||||||
Column(
|
|
||||||
children: [
|
|
||||||
ListView(
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics:
|
|
||||||
const NeverScrollableScrollPhysics(),
|
|
||||||
children: [
|
|
||||||
...controller.model.transactions.map(
|
|
||||||
(e) =>
|
|
||||||
_buildTransactionItem(e, context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
SizedBox(height: 10),
|
|
||||||
if (controller.model.pageableModel !=
|
|
||||||
null &&
|
|
||||||
controller.model.pageableModel!.number <
|
|
||||||
controller
|
|
||||||
.model
|
|
||||||
.pageableModel!
|
|
||||||
.totalPages)
|
|
||||||
OutlinedButton(
|
|
||||||
child: Text('Load more'),
|
|
||||||
onPressed: () {
|
|
||||||
controller.getTransactions({
|
|
||||||
'userId': prefs.getString(
|
|
||||||
"userId",
|
|
||||||
)!,
|
|
||||||
'page':
|
|
||||||
(controller
|
|
||||||
.model
|
|
||||||
.pageableModel!
|
|
||||||
.number +
|
|
||||||
1)
|
|
||||||
.toString(),
|
|
||||||
'size': _pageSize.toString(),
|
|
||||||
'sort': 'createdAt,desc',
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (controller.model.selectMode) _buildSelectionBottomBar(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSearchField(HistoryController controller) {
|
Widget _buildSearchRow() {
|
||||||
return Column(
|
final hasFilters =
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
controller.model.statusFilter != null ||
|
||||||
children: [
|
controller.model.typeFilter != null;
|
||||||
Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
textInputAction: TextInputAction.search,
|
|
||||||
onFieldSubmitted: (value) {
|
|
||||||
_queryParams.clear();
|
|
||||||
setState(() {
|
|
||||||
_queryParams['creditAccount'] = _searchController.text;
|
|
||||||
_queryParams['creditEmail'] = _searchController.text;
|
|
||||||
_queryParams['creditPhone'] = _searchController.text;
|
|
||||||
_queryParams['creditName'] = _searchController.text;
|
|
||||||
_queryParams['billName'] = _searchController.text;
|
|
||||||
_queryParams['amount'] = _searchController.text;
|
|
||||||
_queryParams['userId'] = prefs.getString("userId")!;
|
|
||||||
|
|
||||||
controller.getTransactions(_queryParams);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
controller: _searchController,
|
controller: _searchController,
|
||||||
keyboardType: TextInputType.text,
|
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Search by name, account, email, phone',
|
hintText: 'Search by name, account, email, phone',
|
||||||
focusedErrorBorder: OutlineInputBorder(
|
prefixIcon: const Icon(Icons.search),
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: Theme.of(context).colorScheme.secondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.primary.withValues(alpha: 0.2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
suffixIcon: _searchController.text.isNotEmpty
|
suffixIcon: _searchController.text.isNotEmpty
|
||||||
? IconButton(
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.clear),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
_queryParams.clear();
|
final userId = prefs.getString("userId") ?? '';
|
||||||
controller.getTransactions({
|
controller.searchTransactions(
|
||||||
'userId': prefs.getString("userId")!,
|
userId,
|
||||||
});
|
status: controller.model.statusFilter,
|
||||||
|
type: controller.model.typeFilter,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
icon: Icon(Icons.clear),
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
),
|
),
|
||||||
onChanged: (value) {
|
focusedBorder: OutlineInputBorder(
|
||||||
// controller.updateCreditAccount(value);
|
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,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
validator: (value) {
|
),
|
||||||
if (value == null || value.isEmpty) {
|
),
|
||||||
return 'Please enter an amount';
|
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),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (double.tryParse(value) == null) {
|
|
||||||
return 'Please enter a valid amount';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTransactionItem(Map<String, dynamic> e, BuildContext context) {
|
Widget _buildTransactionItem(Map<String, dynamic> e, BuildContext context) {
|
||||||
int index = controller.model.transactions.indexOf(e);
|
final id = e['id'] as String? ?? '';
|
||||||
Animation<Offset> animation =
|
final isSelected = controller.model.selectedTransactionIds.contains(id);
|
||||||
Tween<Offset>(begin: Offset(0, -0.25), end: Offset.zero).animate(
|
final inSelectMode = controller.model.selectMode;
|
||||||
CurvedAnimation(
|
|
||||||
parent: _controller,
|
|
||||||
curve: Interval(0.175 * index, 1.0, curve: Curves.easeIn),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return SlideTransition(
|
return Stack(
|
||||||
position: animation,
|
children: [
|
||||||
child: TransactionItemWidget(
|
TransactionItemWidget(
|
||||||
transaction: e,
|
transaction: e,
|
||||||
enabled: controller.model.isLoading,
|
enabled: controller.model.isLoading,
|
||||||
onRepeat: () async {
|
onRepeat: inSelectMode
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
await _repeatTransaction(e);
|
await _repeatTransaction(e);
|
||||||
if (context.mounted) {
|
|
||||||
context.push('/make-payment');
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ class HomeController extends ChangeNotifier {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
Map<String, dynamic> response = await http.get(
|
Map<String, dynamic> response = await http.get(
|
||||||
'/public/transaction?sort=createdAt,desc&size=3&page=0&userId=$userId',
|
'/public/transaction?sort=createdAt,desc&size=3&page=0&partyType=INDIVIDUAL&userId=$userId',
|
||||||
);
|
);
|
||||||
|
|
||||||
PageableModel pageableModel = PageableModel.fromJson(response);
|
PageableModel pageableModel = PageableModel.fromJson(response);
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:qpay/auth_state.dart';
|
||||||
import 'package:qpay/models/responsive_policy.dart';
|
import 'package:qpay/models/responsive_policy.dart';
|
||||||
import 'package:qpay/screens/home/home_controller.dart';
|
import 'package:qpay/screens/home/home_controller.dart';
|
||||||
import 'package:qpay/screens/receipt/receipt_controller.dart';
|
import 'package:qpay/screens/receipt/receipt_controller.dart';
|
||||||
import 'package:qpay/screens/transaction_controller.dart';
|
import 'package:qpay/screens/transaction_controller.dart';
|
||||||
import 'package:qpay/screens/transactions/transaction_model.dart' as txn;
|
import 'package:qpay/screens/transactions/transaction_model.dart' as txn;
|
||||||
|
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||||
import 'package:qpay/widgets/transaction_item_widget.dart';
|
import 'package:qpay/widgets/transaction_item_widget.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
@@ -194,6 +196,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
|
final isMobile = constraints.maxWidth < ResponsivePolicy.md;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: isDark
|
backgroundColor: isDark
|
||||||
? const Color(0xFF0D0D0D)
|
? const Color(0xFF0D0D0D)
|
||||||
@@ -206,6 +210,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
slivers: [
|
slivers: [
|
||||||
|
if (isMobile)
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
pinned: false,
|
pinned: false,
|
||||||
floating: true,
|
floating: true,
|
||||||
@@ -214,60 +219,11 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
backgroundColor: isDark
|
backgroundColor: isDark
|
||||||
? const Color(0xFF0D0D0D)
|
? const Color(0xFF0D0D0D)
|
||||||
: const Color(0xFFF8F9FA),
|
: const Color(0xFFF8F9FA),
|
||||||
title: constraints.maxWidth < ResponsivePolicy.md
|
title: Image(
|
||||||
? Image(
|
|
||||||
image: const AssetImage('assets/velocity.png'),
|
image: const AssetImage('assets/velocity.png'),
|
||||||
width: 130,
|
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,
|
actions: _buildAppBarActions(theme, isDark),
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
theme.colorScheme.primary.withValues(
|
|
||||||
alpha: 0.15,
|
|
||||||
),
|
|
||||||
theme.colorScheme.tertiary.withValues(
|
|
||||||
alpha: 0.05,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
stops: const [0.3, 0.7],
|
|
||||||
begin: Alignment.topRight,
|
|
||||||
end: Alignment.bottomLeft,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
initials,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: isDark ? Colors.white : Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: Icon(
|
|
||||||
Icons.logout_rounded,
|
|
||||||
color: isDark ? Colors.white54 : Colors.black54,
|
|
||||||
size: 20,
|
|
||||||
),
|
|
||||||
onPressed: _showLogoutDialog,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -282,6 +238,10 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
if (!isMobile) ...[
|
||||||
|
_buildWebHeader(theme, isDark),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
FadeTransition(
|
FadeTransition(
|
||||||
opacity: _providersFadeAnimation,
|
opacity: _providersFadeAnimation,
|
||||||
child: SlideTransition(
|
child: SlideTransition(
|
||||||
@@ -323,6 +283,71 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// App Bar Actions (shared between mobile SliverAppBar and web header)
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
List<Widget> _buildAppBarActions(ThemeData theme, bool isDark) {
|
||||||
|
return [
|
||||||
|
if (_isLoggedIn) ...[
|
||||||
|
Container(
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
|
margin: const EdgeInsets.only(right: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color: isDark
|
||||||
|
? Colors.white.withValues(alpha: 0.12)
|
||||||
|
: Colors.black87.withAlpha(20),
|
||||||
|
),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
theme.colorScheme.primary.withValues(alpha: 0.15),
|
||||||
|
theme.colorScheme.tertiary.withValues(alpha: 0.05),
|
||||||
|
],
|
||||||
|
stops: const [0.3, 0.7],
|
||||||
|
begin: Alignment.topRight,
|
||||||
|
end: Alignment.bottomLeft,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
initials,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: isDark ? Colors.white : Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Icons.logout_rounded,
|
||||||
|
color: isDark ? Colors.white54 : Colors.black54,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
onPressed: _showLogoutDialog,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// Web Header (replaces SliverAppBar on web/tablet)
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
Widget _buildWebHeader(ThemeData theme, bool isDark) {
|
||||||
|
return Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: _buildAppBarActions(theme, isDark),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
// Providers Section
|
// Providers Section
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
@@ -384,21 +409,17 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
_isZWGSelected = !_isZWGSelected;
|
_isZWGSelected = !_isZWGSelected;
|
||||||
});
|
});
|
||||||
if (_isZWGSelected) {
|
if (_isZWGSelected) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppSnackBar.show(
|
||||||
SnackBar(
|
context,
|
||||||
content: Center(
|
|
||||||
child: const Text(
|
|
||||||
'ZWG currency not supported yet',
|
'ZWG currency not supported yet',
|
||||||
|
customContent: const Center(
|
||||||
|
child: Text('ZWG currency not supported yet'),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
backgroundColor: theme.colorScheme.primary,
|
backgroundColor: theme.colorScheme.primary,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
duration: const Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
width: 300,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -749,28 +770,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Register or Login for more',
|
'Register or Login for more features',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: isDark ? Colors.white : Colors.black87,
|
color: isDark ? Colors.white : Colors.black87,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
|
||||||
InkWell(
|
|
||||||
onTap: () => _showFeaturesPopup(context),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
child: Text(
|
|
||||||
'features',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
decoration: TextDecoration.underline,
|
|
||||||
decorationColor: theme.colorScheme.primary,
|
|
||||||
color: theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -825,6 +830,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_isLoggedIn = false;
|
_isLoggedIn = false;
|
||||||
});
|
});
|
||||||
|
authState.refresh();
|
||||||
setupDataAndTransitions();
|
setupDataAndTransitions();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -833,201 +839,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,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:qpay/models/responsive_policy.dart';
|
import 'package:qpay/screens/onboarding/widgets/onboarding_layout.dart';
|
||||||
|
|
||||||
class CompleteScreen extends StatefulWidget {
|
class CompleteScreen extends StatefulWidget {
|
||||||
const CompleteScreen({super.key});
|
const CompleteScreen({super.key});
|
||||||
@@ -12,67 +12,79 @@ class CompleteScreen extends StatefulWidget {
|
|||||||
class _CompleteScreenState extends State<CompleteScreen> {
|
class _CompleteScreenState extends State<CompleteScreen> {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
body: SingleChildScrollView(
|
return OnboardingLayout(
|
||||||
child: Padding(
|
// No back button - the user shouldn't be able to go back to the
|
||||||
padding: const EdgeInsets.all(25),
|
// verification step after their account is fully set up.
|
||||||
child: Center(
|
showBackButton: false,
|
||||||
child: LayoutBuilder(
|
child: OnboardingCard(
|
||||||
builder: (context, constraints) {
|
|
||||||
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
|
|
||||||
? ResponsivePolicy.md.toDouble()
|
|
||||||
: constraints.maxWidth;
|
|
||||||
return SizedBox(
|
|
||||||
width: maxWidth,
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
SizedBox(height: 75),
|
// Success badge - green to differentiate from the brand primary.
|
||||||
Text(
|
Container(
|
||||||
'Registration Complete',
|
padding: const EdgeInsets.all(20),
|
||||||
style: TextStyle(
|
decoration: BoxDecoration(
|
||||||
fontSize: 30,
|
color: Colors.green.withValues(alpha: 0.12),
|
||||||
fontWeight: FontWeight.bold,
|
shape: BoxShape.circle,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.check_circle_rounded,
|
||||||
|
color: Colors.green,
|
||||||
|
size: 56,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 5),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
'Thank you for registering with Velocity payments',
|
"You're all set!",
|
||||||
style: TextStyle(fontSize: 18),
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
letterSpacing: -0.5,
|
||||||
),
|
),
|
||||||
Image.asset('assets/landing.png', width: 300),
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Tap Login to get started',
|
'Welcome to Velocity. Your account is ready — sign in to start sending and receiving payments.',
|
||||||
style: TextStyle(fontSize: 18),
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.65),
|
||||||
|
height: 1.45,
|
||||||
),
|
),
|
||||||
SizedBox(height: 30),
|
),
|
||||||
Padding(
|
const SizedBox(height: 28),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 25),
|
Container(
|
||||||
child: Row(
|
decoration: BoxDecoration(
|
||||||
children: [
|
borderRadius: BorderRadius.circular(16),
|
||||||
Expanded(
|
boxShadow: [
|
||||||
child: ElevatedButton(
|
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: () {
|
onPressed: () {
|
||||||
context.go('/onboarding/login');
|
context.go('/onboarding/login');
|
||||||
},
|
},
|
||||||
child: Text('Go to Login', style: TextStyle(fontSize: 16)),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 5),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import 'package:qpay/models/responsive_policy.dart';
|
import 'package:qpay/models/responsive_policy.dart';
|
||||||
import 'package:qpay/screens/onboarding/login/login_controller.dart';
|
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
|
||||||
|
|
||||||
class LandingScreen extends StatefulWidget {
|
class LandingScreen extends StatefulWidget {
|
||||||
const LandingScreen({super.key});
|
const LandingScreen({super.key});
|
||||||
@@ -11,82 +12,113 @@ class LandingScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LandingScreenState extends State<LandingScreen> {
|
class _LandingScreenState extends State<LandingScreen> {
|
||||||
|
late OnboardingController onboardingController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
onboardingController = Provider.of<OnboardingController>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _goToRegister() {
|
||||||
|
// Reset the registration state so a fresh sign-up starts cleanly.
|
||||||
|
onboardingController.resetState();
|
||||||
|
context.go('/onboarding/phone');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Max content width on large screens (desktop / web).
|
||||||
|
static const double _maxContentWidth = 1200;
|
||||||
|
// Max content width on tablet — the current mobile cap lifted slightly
|
||||||
|
// so the page breathes a bit on landscape phones / small tablets.
|
||||||
|
static const double _maxTabletWidth = 760;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final colorScheme = theme.colorScheme;
|
||||||
|
final width = MediaQuery.of(context).size.width;
|
||||||
|
final isTablet = width >= ResponsivePolicy.md;
|
||||||
|
final isDesktop = width >= ResponsivePolicy.lg;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: SingleChildScrollView(
|
// Use a Stack so the gradient background is guaranteed to cover the
|
||||||
child: Padding(
|
// full viewport (including the area under the SafeArea) regardless
|
||||||
padding: const EdgeInsets.all(25),
|
// of how tall the content ends up being.
|
||||||
child: Center(
|
body: Stack(
|
||||||
child: LayoutBuilder(
|
|
||||||
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: [
|
children: [
|
||||||
SizedBox(height: 75),
|
Positioned.fill(
|
||||||
Text(
|
child: DecoratedBox(
|
||||||
'Welcome to Velocity',
|
decoration: BoxDecoration(
|
||||||
style: TextStyle(
|
gradient: LinearGradient(
|
||||||
fontSize: 30,
|
begin: Alignment.topLeft,
|
||||||
fontWeight: FontWeight.bold,
|
end: Alignment.bottomRight,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
colors: [
|
||||||
),
|
colorScheme.primary.withValues(alpha: 0.08),
|
||||||
),
|
colorScheme.surface,
|
||||||
SizedBox(height: 5),
|
colorScheme.secondary.withValues(alpha: 0.05),
|
||||||
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),
|
),
|
||||||
|
),
|
||||||
|
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),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.go('/');
|
context.go('/');
|
||||||
},
|
},
|
||||||
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
|
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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -95,6 +127,530 @@ class _LandingScreenState extends State<LandingScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 splitting bills, paying teams, or settling group purchases 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:dio/dio.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:qpay/auth_state.dart';
|
||||||
import 'package:qpay/http/http.dart';
|
import 'package:qpay/http/http.dart';
|
||||||
import 'package:qpay/models/api_response.dart';
|
import 'package:qpay/models/api_response.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
@@ -60,15 +61,22 @@ class LoginController extends ChangeNotifier {
|
|||||||
prefs.setString('username', username);
|
prefs.setString('username', username);
|
||||||
prefs.setString('email', response['email']);
|
prefs.setString('email', response['email']);
|
||||||
prefs.setString('firstName', response['firstName']);
|
prefs.setString('firstName', response['firstName']);
|
||||||
prefs.setString('lastName', response['lastName']);
|
prefs.setString('lastName', response['lastName'] ?? '');
|
||||||
prefs.setString('phone', response['phone']);
|
prefs.setString('phone', response['phone']);
|
||||||
prefs.setString('userId', response['uuid']);
|
prefs.setString('userId', response['uuid']);
|
||||||
prefs.setString(
|
prefs.setString(
|
||||||
'initials',
|
'initials',
|
||||||
response['firstName'].substring(0, 1) +
|
response['firstName'].substring(0, 1) +
|
||||||
response['lastName'].substring(0, 1),
|
(response['lastName'] != null && response['lastName'].isNotEmpty
|
||||||
|
? response['lastName'].substring(0, 1)
|
||||||
|
: ''),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Notify the router that the user is now signed in so any
|
||||||
|
// auth-gated redirects (e.g. the groups flow) are re-evaluated
|
||||||
|
// and the user is sent to their originally requested page.
|
||||||
|
await authState.refresh();
|
||||||
|
|
||||||
return ApiResponse.success(model);
|
return ApiResponse.success(model);
|
||||||
} else {
|
} else {
|
||||||
model.errorMessage =
|
model.errorMessage =
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:convert';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:google_sign_in/google_sign_in.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:google_sign_in_web/web_only.dart' as web;
|
|
||||||
import 'package:qpay/models/responsive_policy.dart';
|
|
||||||
import 'package:qpay/screens/onboarding/login/login_controller.dart';
|
import 'package:qpay/screens/onboarding/login/login_controller.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
|
||||||
|
import 'package:qpay/screens/onboarding/widgets/onboarding_layout.dart';
|
||||||
|
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
|
|
||||||
class LoginScreen extends StatefulWidget {
|
class LoginScreen extends StatefulWidget {
|
||||||
@@ -20,242 +15,151 @@ class LoginScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LoginScreenState extends State<LoginScreen> {
|
class _LoginScreenState extends State<LoginScreen> {
|
||||||
late SharedPreferences prefs;
|
late OnboardingController onboardingController;
|
||||||
late GoogleSignIn googleSignIn;
|
|
||||||
|
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
|
final TextEditingController _emailController = TextEditingController();
|
||||||
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
|
|
||||||
|
bool _obscurePassword = true;
|
||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
|
|
||||||
List<String> scopes = <String>[
|
|
||||||
'https://www.googleapis.com/auth/contacts.readonly',
|
|
||||||
];
|
|
||||||
|
|
||||||
late LoginController _loginController;
|
late LoginController _loginController;
|
||||||
StreamSubscription<GoogleSignInAuthenticationEvent>? _authSubscription;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_loginController = LoginController(context);
|
_loginController = LoginController(context);
|
||||||
|
onboardingController = Provider.of<OnboardingController>(
|
||||||
googleSignIn = GoogleSignIn.instance;
|
context,
|
||||||
if (kIsWeb) {
|
listen: false,
|
||||||
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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_authSubscription?.cancel();
|
_emailController.dispose();
|
||||||
|
_passwordController.dispose();
|
||||||
_loginController.dispose();
|
_loginController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
Future<void> _onLogin() async {
|
||||||
Widget build(BuildContext context) {
|
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||||
return Scaffold(
|
|
||||||
body: SingleChildScrollView(
|
final email = _emailController.text.trim();
|
||||||
child: Padding(
|
final password = _passwordController.text;
|
||||||
padding: const EdgeInsets.all(25),
|
|
||||||
child: Form(
|
// Cache credentials in the onboarding model for use by the rest
|
||||||
key: _formKey,
|
// of the registration/login flow.
|
||||||
child: Center(
|
onboardingController.updateEmail(email);
|
||||||
child: LayoutBuilder(
|
onboardingController.updatePassword(password);
|
||||||
builder: (context, constraints) {
|
|
||||||
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
|
setState(() => _loading = true);
|
||||||
? ResponsivePolicy.md.toDouble()
|
|
||||||
: constraints.maxWidth;
|
final response = await _loginController.login(email, password);
|
||||||
return SizedBox(
|
|
||||||
width: maxWidth,
|
if (!mounted) return;
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
setState(() => _loading = false);
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
if (response.isSuccess) {
|
||||||
SizedBox(height: 75),
|
AppSnackBar.showSuccess(context, 'Login successful!');
|
||||||
Text(
|
} else {
|
||||||
'Login',
|
AppSnackBar.showError(context, response.error ?? 'Login failed');
|
||||||
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),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildGoogleButton() {
|
if (!mounted) return;
|
||||||
return Skeletonizer(
|
|
||||||
enabled: _loading,
|
if (response.isSuccess) {
|
||||||
child: Row(
|
context.go('/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
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: [
|
children: [
|
||||||
Expanded(
|
const OnboardingHero(
|
||||||
child: OutlinedButton.icon(
|
icon: Icons.lock_open_rounded,
|
||||||
onPressed: () {
|
title: 'Welcome back',
|
||||||
signInWithGoogle();
|
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;
|
||||||
},
|
},
|
||||||
icon: Image.asset('assets/google.png', width: 20, height: 20),
|
),
|
||||||
label: Text('Google', style: TextStyle(fontSize: 16)),
|
const SizedBox(height: 14),
|
||||||
style: OutlinedButton.styleFrom(
|
OnboardingTextField(
|
||||||
padding: EdgeInsets.symmetric(vertical: 16),
|
controller: _passwordController,
|
||||||
side: BorderSide(color: Colors.grey.shade300),
|
hintText: 'Password',
|
||||||
shape: RoundedRectangleBorder(
|
prefixIcon: Icons.lock_outline,
|
||||||
borderRadius: BorderRadius.circular(12),
|
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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(width: 16),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,50 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:google_sign_in/google_sign_in.dart';
|
|
||||||
import 'package:qpay/http/http.dart';
|
|
||||||
|
|
||||||
class OnboardingModel {
|
class RegistrationModel {
|
||||||
|
String? email;
|
||||||
|
String? firstName;
|
||||||
|
String? lastName;
|
||||||
String? phone;
|
String? phone;
|
||||||
|
String? password;
|
||||||
String? workflowId;
|
String? workflowId;
|
||||||
String? username;
|
|
||||||
GoogleSignInAccount? googleUser;
|
|
||||||
bool isLoading = false;
|
bool isLoading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
class OnboardingController extends ChangeNotifier {
|
class OnboardingController extends ChangeNotifier {
|
||||||
OnboardingModel model = OnboardingModel();
|
RegistrationModel model = RegistrationModel();
|
||||||
|
|
||||||
void resetState() {
|
void resetState() {
|
||||||
model = OnboardingModel();
|
model = RegistrationModel();
|
||||||
}
|
}
|
||||||
|
|
||||||
void updateModel(Map mapModel) {
|
void updateModel(Map mapModel) {
|
||||||
model.workflowId = mapModel['workflowId'];
|
model.workflowId = mapModel['workflowId'];
|
||||||
model.phone = mapModel['phone'];
|
model.phone = mapModel['phone'];
|
||||||
model.username = mapModel['username'];
|
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
updatePhone(String phone) {
|
void updateEmail(String email) {
|
||||||
|
model.email = email;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateFirstName(String firstName) {
|
||||||
|
model.firstName = firstName;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateLastName(String lastName) {
|
||||||
|
model.lastName = lastName;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void updatePhone(String phone) {
|
||||||
model.phone = phone;
|
model.phone = phone;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
updateGoogleUser(GoogleSignInAccount googleUser) {
|
void updatePassword(String password) {
|
||||||
model.googleUser = googleUser;
|
model.password = password;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Future<ApiResponse<Map<String, dynamic>>> register() async {
|
Future<ApiResponse<Map<String, dynamic>>> register() async {
|
||||||
prefs = await SharedPreferences.getInstance();
|
prefs = await SharedPreferences.getInstance();
|
||||||
|
|
||||||
Map user = {
|
Map user = {
|
||||||
"username": onboardingController.model.googleUser?.email,
|
"username": onboardingController.model.email,
|
||||||
"email": onboardingController.model.googleUser?.email,
|
"email": onboardingController.model.email,
|
||||||
"firstName": onboardingController.model.googleUser?.displayName?.split(" ").first,
|
"firstName": onboardingController.model.firstName,
|
||||||
"lastName": onboardingController.model.googleUser?.displayName?.split(" ").last,
|
"lastName": onboardingController.model.lastName,
|
||||||
"password": onboardingController.model.googleUser?.id,
|
"password": onboardingController.model.password,
|
||||||
"photoUrl": onboardingController.model.googleUser?.photoUrl,
|
|
||||||
"phone": onboardingController.model.phone,
|
"phone": onboardingController.model.phone,
|
||||||
"tempUid": prefs.get("userId")
|
"tempUid": prefs.get("userId"),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
model.isLoading = true;
|
model.isLoading = true;
|
||||||
|
notifyListeners();
|
||||||
|
|
||||||
Map response = await http.post(
|
Map response = await http.post(
|
||||||
'/auth/register',
|
'/auth/register',
|
||||||
user
|
user,
|
||||||
);
|
);
|
||||||
model.status = response['state'];
|
model.status = response['state'];
|
||||||
if(response['state'] == 'failed') {
|
if (response['state'] == 'failed') {
|
||||||
model.errorMessage = response['message'];
|
model.errorMessage = response['message'];
|
||||||
showErrorSnackBar(model.errorMessage);
|
showErrorSnackBar(model.errorMessage);
|
||||||
model.isLoading = false;
|
model.isLoading = false;
|
||||||
@@ -79,8 +78,9 @@ class PhoneController extends AbstractController {
|
|||||||
);
|
);
|
||||||
model.isLoading = false;
|
model.isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return ApiResponse.failure("Problem during registration, please try again or contact support?");
|
return ApiResponse.failure(
|
||||||
|
"Problem during registration, please try again or contact support?",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_contacts/flutter_contacts.dart';
|
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:qpay/models/responsive_policy.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
|
||||||
import 'package:qpay/screens/onboarding/phone/phone_controller.dart';
|
import 'package:qpay/screens/onboarding/phone/phone_controller.dart';
|
||||||
|
import 'package:qpay/screens/onboarding/widgets/onboarding_layout.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
|
|
||||||
class PhoneScreen extends StatefulWidget {
|
class PhoneScreen extends StatefulWidget {
|
||||||
@@ -14,10 +15,18 @@ class PhoneScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _PhoneScreenState extends State<PhoneScreen> {
|
class _PhoneScreenState extends State<PhoneScreen> {
|
||||||
late PhoneController phoneController;
|
late PhoneController phoneController;
|
||||||
|
late OnboardingController onboardingController;
|
||||||
final _formKey = GlobalKey<FormState>();
|
final _formKey = GlobalKey<FormState>();
|
||||||
final TextEditingController _phoneController = TextEditingController();
|
final TextEditingController _phoneController = TextEditingController();
|
||||||
|
final TextEditingController _emailController = TextEditingController();
|
||||||
|
final TextEditingController _passwordController = TextEditingController();
|
||||||
|
final TextEditingController _confirmPasswordController =
|
||||||
|
TextEditingController();
|
||||||
|
final TextEditingController _fullNameController = TextEditingController();
|
||||||
|
|
||||||
String selectedCountryCode = '+263'; // Default to ZW
|
String selectedCountryCode = '+263'; // Default to ZW
|
||||||
|
bool _obscurePassword = true;
|
||||||
|
bool _obscureConfirmPassword = true;
|
||||||
|
|
||||||
// Common country codes with flags and names
|
// Common country codes with flags and names
|
||||||
final List<Map<String, String>> countryCodes = [
|
final List<Map<String, String>> countryCodes = [
|
||||||
@@ -48,27 +57,81 @@ class _PhoneScreenState extends State<PhoneScreen> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
phoneController = PhoneController(context);
|
phoneController = PhoneController(context);
|
||||||
|
onboardingController = Provider.of<OnboardingController>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
);
|
||||||
|
// Pre-fill the email field if it has already been captured.
|
||||||
|
final existingEmail = onboardingController.model.email;
|
||||||
|
if (existingEmail != null && existingEmail.isNotEmpty) {
|
||||||
|
_emailController.text = existingEmail;
|
||||||
|
}
|
||||||
|
// Pre-fill the password field if it has already been captured.
|
||||||
|
final existingPassword = onboardingController.model.password;
|
||||||
|
if (existingPassword != null && existingPassword.isNotEmpty) {
|
||||||
|
_passwordController.text = existingPassword;
|
||||||
|
_confirmPasswordController.text = existingPassword;
|
||||||
|
}
|
||||||
|
// Pre-fill the full name from firstName + lastName if previously captured.
|
||||||
|
final firstName = onboardingController.model.firstName;
|
||||||
|
final lastName = onboardingController.model.lastName;
|
||||||
|
final combined = [
|
||||||
|
if (firstName != null && firstName.isNotEmpty) firstName,
|
||||||
|
if (lastName != null && lastName.isNotEmpty) lastName,
|
||||||
|
].join(' ');
|
||||||
|
if (combined.isNotEmpty) {
|
||||||
|
_fullNameController.text = combined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updatePhoneNumber(String phoneNumber) async {
|
@override
|
||||||
String existingPhone = phoneNumber;
|
void dispose() {
|
||||||
if (existingPhone.isNotEmpty) {
|
_phoneController.dispose();
|
||||||
// Try to extract country code from existing phone number
|
_emailController.dispose();
|
||||||
for (var country in countryCodes) {
|
_passwordController.dispose();
|
||||||
if (existingPhone.startsWith(country['code']!)) {
|
_confirmPasswordController.dispose();
|
||||||
setState(() {
|
_fullNameController.dispose();
|
||||||
selectedCountryCode = country['code']!;
|
super.dispose();
|
||||||
});
|
|
||||||
_phoneController.text = existingPhone.substring(
|
|
||||||
country['code']!.length,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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: '');
|
||||||
}
|
}
|
||||||
// If no country code found, use default and set the full number
|
final parts = trimmed.split(RegExp(r'\s+'));
|
||||||
if (_phoneController.text.isEmpty) {
|
if (parts.length == 1) {
|
||||||
_phoneController.text = existingPhone;
|
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,148 +140,171 @@ class _PhoneScreenState extends State<PhoneScreen> {
|
|||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: phoneController,
|
listenable: phoneController,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
return Scaffold(
|
return OnboardingLayout(
|
||||||
body: SingleChildScrollView(
|
showBackButton: true,
|
||||||
child: Padding(
|
trailingLabel: 'Sign in',
|
||||||
padding: const EdgeInsets.all(25.0),
|
trailingRoute: '/onboarding/login',
|
||||||
|
trailingIcon: Icons.login_rounded,
|
||||||
|
bottomLinkLabel: 'Continue as guest?',
|
||||||
|
bottomLinkRoute: '/',
|
||||||
|
child: OnboardingCard(
|
||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
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(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
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,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
'(as well as to the email address from your social media account if available)',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
color: Colors.grey,
|
|
||||||
),
|
|
||||||
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: [
|
children: [
|
||||||
Expanded(
|
const OnboardingHero(
|
||||||
child: ElevatedButton(
|
icon: Icons.person_add_alt_1_rounded,
|
||||||
onPressed: () async {
|
title: 'Create your account',
|
||||||
if (_formKey.currentState!
|
subtitle:
|
||||||
.validate()) {
|
'It only takes a minute — we\'ll send a verification code to confirm your details.',
|
||||||
String fullPhoneNumber =
|
),
|
||||||
_phoneController.text;
|
const SizedBox(height: 28),
|
||||||
|
OnboardingTextField(
|
||||||
phoneController.updatePhone(
|
controller: _fullNameController,
|
||||||
fullPhoneNumber,
|
hintText: 'Full name',
|
||||||
);
|
prefixIcon: Icons.person_outline,
|
||||||
await phoneController.register();
|
textCapitalization: TextCapitalization.words,
|
||||||
if (phoneController.model.status !=
|
validator: (value) {
|
||||||
'failed') {
|
if (value == null || value.trim().isEmpty) {
|
||||||
context.go('/onboarding/verify');
|
return 'Please enter your full name';
|
||||||
}
|
}
|
||||||
|
if (value.trim().length < 2) {
|
||||||
|
return 'Name is too short';
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
},
|
},
|
||||||
child: Text(
|
|
||||||
'Send Code',
|
|
||||||
style: TextStyle(fontSize: 16),
|
|
||||||
),
|
),
|
||||||
|
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,
|
||||||
),
|
),
|
||||||
SizedBox(width: 16),
|
tooltip: _obscurePassword
|
||||||
Expanded(
|
? 'Show password'
|
||||||
child: OutlinedButton(
|
: 'Hide password',
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
context.go('/onboarding/login');
|
setState(() {
|
||||||
|
_obscurePassword = !_obscurePassword;
|
||||||
|
});
|
||||||
},
|
},
|
||||||
child: Text(
|
|
||||||
'Go to Login',
|
|
||||||
style: TextStyle(fontSize: 16),
|
|
||||||
),
|
),
|
||||||
|
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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(height: 10),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () {
|
|
||||||
context.go('/');
|
|
||||||
},
|
|
||||||
child: Text(
|
|
||||||
'Continue as guest?',
|
|
||||||
style: TextStyle(fontSize: 14),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPhoneField() {
|
Widget _buildPhoneField() {
|
||||||
return Column(
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
// Country code dropdown
|
// Country code dropdown
|
||||||
Container(
|
SizedBox(
|
||||||
width: 100,
|
width: 110,
|
||||||
height: 55,
|
child: Container(
|
||||||
|
height: 47,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
|
color: colorScheme.surface,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
|
color: colorScheme.onSurface.withValues(alpha: 0.12),
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
child: DropdownButtonHideUnderline(
|
child: DropdownButtonHideUnderline(
|
||||||
child: DropdownButton<String>(
|
child: DropdownButton<String>(
|
||||||
value: selectedCountryCode,
|
value: selectedCountryCode,
|
||||||
isExpanded: true,
|
isExpanded: true,
|
||||||
icon: const Icon(Icons.arrow_drop_down),
|
icon: const Icon(Icons.arrow_drop_down_rounded),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
items: countryCodes.map((country) {
|
items: countryCodes.map((country) {
|
||||||
return DropdownMenuItem<String>(
|
return DropdownMenuItem<String>(
|
||||||
value: country['code'],
|
value: country['code'],
|
||||||
@@ -226,10 +312,13 @@ class _PhoneScreenState extends State<PhoneScreen> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(country['flag']!),
|
Text(country['flag']!),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text(
|
||||||
country['code']!,
|
country['code']!,
|
||||||
style: const TextStyle(fontSize: 16),
|
style: const TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -245,35 +334,21 @@ class _PhoneScreenState extends State<PhoneScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
// Phone number field
|
// Phone number field
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TextFormField(
|
child: OnboardingTextField(
|
||||||
controller: _phoneController,
|
controller: _phoneController,
|
||||||
|
hintText: 'Phone number',
|
||||||
|
prefixIcon: Icons.phone_outlined,
|
||||||
keyboardType: TextInputType.numberWithOptions(decimal: true),
|
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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
enabledBorder: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
borderSide: BorderSide(
|
|
||||||
color: Theme.of(
|
|
||||||
context,
|
|
||||||
).colorScheme.primary.withOpacity(0.2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
validator: (value) {
|
validator: (value) {
|
||||||
if (value == null || value.isEmpty) {
|
if (value == null || value.isEmpty) {
|
||||||
return 'Please enter a phone number';
|
return 'Please enter a phone number';
|
||||||
}
|
}
|
||||||
// Remove any non-digit characters for validation
|
// Remove any non-digit characters for validation
|
||||||
String digitsOnly = value.replaceAll(RegExp(r'[^\d]'), '');
|
final digitsOnly = value.replaceAll(RegExp(r'[^\d]'), '');
|
||||||
if (digitsOnly.length < 7) {
|
if (digitsOnly.length < 7) {
|
||||||
return 'Phone number must be at least 7 digits';
|
return 'Phone number must be at least 7 digits';
|
||||||
}
|
}
|
||||||
@@ -285,8 +360,6 @@ class _PhoneScreenState extends State<PhoneScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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:flutter/cupertino.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:qpay/abstracts/AbstractController.dart';
|
import 'package:qpay/abstracts/AbstractController.dart';
|
||||||
@@ -19,7 +22,7 @@ class VerifyController extends AbstractController {
|
|||||||
|
|
||||||
VerifyModel model = VerifyModel();
|
VerifyModel model = VerifyModel();
|
||||||
|
|
||||||
VerifyController(this.context){
|
VerifyController(this.context) {
|
||||||
onboardingController = Provider.of<OnboardingController>(
|
onboardingController = Provider.of<OnboardingController>(
|
||||||
context,
|
context,
|
||||||
listen: false,
|
listen: false,
|
||||||
@@ -31,9 +34,64 @@ class VerifyController extends AbstractController {
|
|||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ApiResponse<Map<String, dynamic>>> verifyOtp () async {
|
/// Extracts a human-readable error message from any exception thrown during
|
||||||
|
/// an HTTP call. Supports:
|
||||||
|
/// * [DioException] – the response body, request body, and a top-level
|
||||||
|
/// `message` field are inspected.
|
||||||
|
/// * Dart [Exception] / [Error] whose stringified form is a JSON object
|
||||||
|
/// (e.g. `{state: failed, status: manual, body: {...}, message: ...}`).
|
||||||
|
/// * Any other throwable – falls back to its `toString()`.
|
||||||
|
String _extractErrorMessage(Object error) {
|
||||||
|
try {
|
||||||
|
if (error is DioException) {
|
||||||
|
final data = error.response?.data;
|
||||||
|
if (data is Map) {
|
||||||
|
final message = data['message'];
|
||||||
|
if (message is String && message.isNotEmpty) return message;
|
||||||
|
}
|
||||||
|
if (data is String && data.isNotEmpty) {
|
||||||
|
final parsed = _tryParseJson(data);
|
||||||
|
if (parsed is Map) {
|
||||||
|
final message = parsed['message'];
|
||||||
|
if (message is String && message.isNotEmpty) return message;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
if (error.type == DioExceptionType.connectionTimeout ||
|
||||||
|
error.type == DioExceptionType.receiveTimeout ||
|
||||||
|
error.type == DioExceptionType.sendTimeout) {
|
||||||
|
return 'The request timed out. Please check your connection and try again.';
|
||||||
|
}
|
||||||
|
if (error.type == DioExceptionType.connectionError) {
|
||||||
|
return 'No internet connection. Please check your network and try again.';
|
||||||
|
}
|
||||||
|
return error.message ?? 'Network error. Please try again.';
|
||||||
|
}
|
||||||
|
|
||||||
|
final raw = error.toString();
|
||||||
|
// The server sometimes throws a stringified JSON object as an exception.
|
||||||
|
final parsed = _tryParseJson(raw);
|
||||||
|
if (parsed is Map) {
|
||||||
|
final message = parsed['message'];
|
||||||
|
if (message is String && message.isNotEmpty) return message;
|
||||||
|
}
|
||||||
|
return raw;
|
||||||
|
} catch (_) {
|
||||||
|
return 'An unexpected error occurred. Please try again.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic _tryParseJson(String input) {
|
||||||
|
try {
|
||||||
|
return jsonDecode(input);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ApiResponse<Map<String, dynamic>>> verifyOtp() async {
|
||||||
Map user = {
|
Map user = {
|
||||||
"username": onboardingController.model.googleUser?.email,
|
"username": onboardingController.model.email,
|
||||||
"otpCode": model.code,
|
"otpCode": model.code,
|
||||||
"otpType": "REGISTRATION",
|
"otpType": "REGISTRATION",
|
||||||
"workflowId": onboardingController.model.workflowId,
|
"workflowId": onboardingController.model.workflowId,
|
||||||
@@ -41,62 +99,61 @@ class VerifyController extends AbstractController {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
model.isLoading = true;
|
model.isLoading = true;
|
||||||
|
model.errorMessage = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
Map response = await http.post(
|
Map response = await http.post('/auth/verify', user);
|
||||||
'/auth/verify',
|
|
||||||
user
|
|
||||||
);
|
|
||||||
model.status = response['state'];
|
model.status = response['state'];
|
||||||
model.isLoading = false;
|
model.isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
if(response['state'] == 'failed') {
|
if (response['state'] == 'failed') {
|
||||||
model.errorMessage = response['message'];
|
final message =
|
||||||
return ApiResponse.failure(response['message'] ?? "Verification failed");
|
response['message']?.toString() ?? "Verification failed";
|
||||||
|
model.errorMessage = message;
|
||||||
|
return ApiResponse.failure(message);
|
||||||
}
|
}
|
||||||
return ApiResponse.success(Map<String, dynamic>.from(response));
|
return ApiResponse.success(Map<String, dynamic>.from(response));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.e(e);
|
logger.e(e);
|
||||||
|
final message = _extractErrorMessage(e);
|
||||||
|
model.errorMessage = message;
|
||||||
model.isLoading = false;
|
model.isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return ApiResponse.failure(
|
return ApiResponse.failure(message);
|
||||||
"Problem during registration, please try again or contact support?",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<ApiResponse<Map<String, dynamic>>> resendOtp () async {
|
Future<ApiResponse<Map<String, dynamic>>> resendOtp() async {
|
||||||
Map user = {
|
Map user = {
|
||||||
"username": onboardingController.model.googleUser?.email,
|
"username": onboardingController.model.email,
|
||||||
"phone": onboardingController.model.phone,
|
"phone": onboardingController.model.phone,
|
||||||
"workflowId": onboardingController.model.workflowId,
|
"workflowId": onboardingController.model.workflowId,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
model.isLoading = true;
|
model.isLoading = true;
|
||||||
|
model.errorMessage = null;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
Map response = await http.post(
|
Map response = await http.post('/auth/resend', user);
|
||||||
'/auth/resend',
|
|
||||||
user
|
|
||||||
);
|
|
||||||
model.status = response['state'];
|
model.status = response['state'];
|
||||||
model.isLoading = false;
|
model.isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
|
||||||
if(response['state'] == 'failed') {
|
if (response['state'] == 'failed') {
|
||||||
model.errorMessage = response['message'];
|
final message = response['message']?.toString() ?? "Resend failed";
|
||||||
return ApiResponse.failure(response['message'] ?? "Resend failed");
|
model.errorMessage = message;
|
||||||
|
return ApiResponse.failure(message);
|
||||||
}
|
}
|
||||||
return ApiResponse.success(Map<String, dynamic>.from(response));
|
return ApiResponse.success(Map<String, dynamic>.from(response));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.e(e);
|
logger.e(e);
|
||||||
|
final message = _extractErrorMessage(e);
|
||||||
|
model.errorMessage = message;
|
||||||
model.isLoading = false;
|
model.isLoading = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
return ApiResponse.failure(
|
return ApiResponse.failure(message);
|
||||||
"Problem during registration, please try again or contact support?",
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:qpay/models/responsive_policy.dart';
|
|
||||||
import 'package:qpay/screens/onboarding/verify/verify_controller.dart';
|
import 'package:qpay/screens/onboarding/verify/verify_controller.dart';
|
||||||
|
import 'package:qpay/screens/onboarding/widgets/onboarding_layout.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
|
|
||||||
class VerifyScreen extends StatefulWidget {
|
class VerifyScreen extends StatefulWidget {
|
||||||
@@ -18,7 +18,6 @@ class _VerifyScreenState extends State<VerifyScreen> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
verifyController = VerifyController(context);
|
verifyController = VerifyController(context);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +47,37 @@ class _VerifyScreenState extends State<VerifyScreen> {
|
|||||||
void _onCodeChanged(String value, int index) {
|
void _onCodeChanged(String value, int index) {
|
||||||
if (value.length == 1 && index < 5) {
|
if (value.length == 1 && index < 5) {
|
||||||
_focusNodes[index + 1].requestFocus();
|
_focusNodes[index + 1].requestFocus();
|
||||||
|
} else if (value.isEmpty && index > 0) {
|
||||||
|
_focusNodes[index - 1].requestFocus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submitCode() async {
|
||||||
|
showErrorMessage(null);
|
||||||
|
String code = '';
|
||||||
|
for (var controller in _codeControllers) {
|
||||||
|
code += controller.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code.length != 6) {
|
||||||
|
showErrorMessage('Invalid code');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
verifyController.updateCode(code);
|
||||||
|
final response = await verifyController.verifyOtp();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (response.isSuccess) {
|
||||||
|
if (verifyController.model.status == 'done') {
|
||||||
|
context.go('/onboarding/complete');
|
||||||
|
} else {
|
||||||
|
showErrorMessage(
|
||||||
|
verifyController.model.errorMessage ?? 'Verification failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showErrorMessage(
|
||||||
|
response.error ?? 'Verification failed. Please try again.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,170 +86,145 @@ class _VerifyScreenState extends State<VerifyScreen> {
|
|||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: verifyController,
|
listenable: verifyController,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
return Scaffold(
|
return OnboardingLayout(
|
||||||
body: SingleChildScrollView(
|
showBackButton: true,
|
||||||
child: Padding(
|
trailingLabel: 'Sign in',
|
||||||
padding: const EdgeInsets.all(25),
|
trailingRoute: '/onboarding/login',
|
||||||
child: Center(
|
trailingIcon: Icons.login_rounded,
|
||||||
child: LayoutBuilder(
|
bottomLinkLabel: 'Continue as guest?',
|
||||||
builder: (context, constraints) {
|
bottomLinkRoute: '/',
|
||||||
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
|
child: OnboardingCard(
|
||||||
? ResponsivePolicy.md.toDouble()
|
|
||||||
: constraints.maxWidth;
|
|
||||||
return SizedBox(
|
|
||||||
width: maxWidth,
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
|
||||||
children: [
|
children: [
|
||||||
SizedBox(height: 75),
|
const OnboardingHero(
|
||||||
Text(
|
icon: Icons.sms_rounded,
|
||||||
'Verify Your Account',
|
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(
|
style: TextStyle(
|
||||||
fontSize: 30,
|
color: Theme.of(context).colorScheme.error,
|
||||||
fontWeight: FontWeight.bold,
|
fontSize: 14,
|
||||||
color: Theme.of(context).colorScheme.primary,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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
|
const SizedBox(height: 8),
|
||||||
Row(
|
_buildResendRow(),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
const SizedBox(height: 28),
|
||||||
children: List.generate(
|
Skeletonizer(
|
||||||
6,
|
enabled: verifyController.model.isLoading,
|
||||||
(index) => SizedBox(
|
child: OnboardingPrimaryButton(
|
||||||
width: 50,
|
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(
|
child: TextFormField(
|
||||||
controller: _codeControllers[index],
|
controller: _codeControllers[index],
|
||||||
focusNode: _focusNodes[index],
|
focusNode: _focusNodes[index],
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
keyboardType: TextInputType.number,
|
keyboardType: TextInputType.number,
|
||||||
maxLength: 1,
|
maxLength: 1,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: colorScheme.onSurface,
|
||||||
|
),
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
counterText: '',
|
counterText: '',
|
||||||
border: OutlineInputBorder(
|
filled: true,
|
||||||
borderRadius: BorderRadius.circular(12),
|
fillColor: colorScheme.surface,
|
||||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
contentPadding: const EdgeInsets.symmetric(vertical: 14),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.12),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: borderRadius,
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(
|
||||||
color: Theme.of(context).colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
width: 2,
|
width: 1.6,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
borderSide: BorderSide(color: colorScheme.error),
|
||||||
|
),
|
||||||
|
focusedErrorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: colorScheme.error,
|
||||||
|
width: 1.6,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
contentPadding: EdgeInsets.symmetric(vertical: 16),
|
|
||||||
),
|
),
|
||||||
onChanged: (value) => _onCodeChanged(value, index),
|
onChanged: (value) => _onCodeChanged(value, index),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
}),
|
||||||
),
|
);
|
||||||
SizedBox(height: 5),
|
}
|
||||||
if(errorMessage != null)
|
|
||||||
Text(errorMessage ?? '', style: TextStyle(color: Colors.red)),
|
Widget _buildResendRow() {
|
||||||
SizedBox(height: 30),
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
// Resend Code Section
|
return Center(
|
||||||
Row(
|
child: Wrap(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
"Didn't receive the code? ",
|
"Didn't receive the code?",
|
||||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 16),
|
style: TextStyle(
|
||||||
|
color: colorScheme.onSurface.withValues(alpha: 0.65),
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
verifyController.resendOtp();
|
verifyController.resendOtp();
|
||||||
},
|
},
|
||||||
child: Text(
|
style: TextButton.styleFrom(
|
||||||
'Resend Code',
|
foregroundColor: colorScheme.primary,
|
||||||
style: TextStyle(
|
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||||
color: Theme.of(context).colorScheme.primary,
|
textStyle: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
child: const Text('Resend code'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
// 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)),
|
|
||||||
),
|
|
||||||
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:qpay/models/responsive_policy.dart';
|
import 'package:qpay/models/responsive_policy.dart';
|
||||||
import 'package:qpay/screens/pay/pay_controller.dart';
|
import 'package:qpay/screens/pay/pay_controller.dart';
|
||||||
import 'package:qpay/screens/transaction_controller.dart';
|
import 'package:qpay/screens/transaction_controller.dart';
|
||||||
|
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
|
|
||||||
@@ -169,17 +170,11 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
|
|||||||
|
|
||||||
if (!apiResponse.isSuccess) {
|
if (!apiResponse.isSuccess) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
AppSnackBar.showError(
|
||||||
SnackBar(
|
context,
|
||||||
content: Text(
|
|
||||||
controller.model.errorMessage ??
|
controller.model.errorMessage ??
|
||||||
apiResponse.error ??
|
apiResponse.error ??
|
||||||
"Transaction failed",
|
"Transaction failed",
|
||||||
),
|
|
||||||
behavior: SnackBarBehavior.floating,
|
|
||||||
width: 600,
|
|
||||||
backgroundColor: Colors.deepOrange,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else if (controller.model.status == "SUCCESS" && context.mounted) {
|
} else if (controller.model.status == "SUCCESS" && context.mounted) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:gif_view/gif_view.dart';
|
import 'package:gif_view/gif_view.dart';
|
||||||
import 'package:qpay/main.dart';
|
import 'package:qpay/routes.dart';
|
||||||
|
|
||||||
class SplashScreen extends StatefulWidget {
|
class SplashScreen extends StatefulWidget {
|
||||||
const SplashScreen({super.key});
|
const SplashScreen({super.key});
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,11 +40,11 @@ static void my_application_activate(GApplication* application) {
|
|||||||
if (use_header_bar) {
|
if (use_header_bar) {
|
||||||
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||||
gtk_widget_show(GTK_WIDGET(header_bar));
|
gtk_widget_show(GTK_WIDGET(header_bar));
|
||||||
gtk_header_bar_set_title(header_bar, "qpay");
|
gtk_header_bar_set_title(header_bar, "Velocity Pay");
|
||||||
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
||||||
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||||
} else {
|
} else {
|
||||||
gtk_window_set_title(window, "qpay");
|
gtk_window_set_title(window, "Velocity Pay");
|
||||||
}
|
}
|
||||||
|
|
||||||
gtk_window_set_default_size(window, 1280, 720);
|
gtk_window_set_default_size(window, 1280, 720);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import FlutterMacOS
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
import firebase_core
|
import firebase_core
|
||||||
import google_sign_in_ios
|
|
||||||
import path_provider_foundation
|
import path_provider_foundation
|
||||||
import share_plus
|
import share_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
@@ -15,7 +14,6 @@ import webview_flutter_wkwebview
|
|||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||||
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
|||||||
68
pubspec.lock
68
pubspec.lock
@@ -237,26 +237,26 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: firebase_core
|
name: firebase_core
|
||||||
sha256: "132e1c311bc41e7d387b575df0aacdf24efbf4930365eb61042be5bde3978f03"
|
sha256: ec46a100a560d3bd5f97f2d89ba7492cb09b6dd0a4a28753d1258f360d6bd9f9
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.2.0"
|
version: "4.10.0"
|
||||||
firebase_core_platform_interface:
|
firebase_core_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: firebase_core_platform_interface
|
name: firebase_core_platform_interface
|
||||||
sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64
|
sha256: "4a120366dbf7d5a8ee9438978530b664b855728fb8dcc3a201017660817e555b"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.2"
|
version: "7.0.1"
|
||||||
firebase_core_web:
|
firebase_core_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: firebase_core_web
|
name: firebase_core_web
|
||||||
sha256: ecde2def458292404a4fcd3731ee4992fd631a0ec359d2d67c33baa8da5ec8ae
|
sha256: "5ad1be848692ec148f2d6a8ad2a3838cb852ea5f3c9e6479a7afce479e1854f8"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.0"
|
version: "3.8.0"
|
||||||
fixnum:
|
fixnum:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -360,54 +360,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.2"
|
version: "6.3.2"
|
||||||
google_identity_services_web:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: google_identity_services_web
|
|
||||||
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.3.3+1"
|
|
||||||
google_sign_in:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: google_sign_in
|
|
||||||
sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "7.2.0"
|
|
||||||
google_sign_in_android:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: google_sign_in_android
|
|
||||||
sha256: "799165f4c0621ed233bccdded4c2e92739bc1fe73e970163b2f7493b301adad3"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "7.2.1"
|
|
||||||
google_sign_in_ios:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: google_sign_in_ios
|
|
||||||
sha256: d9d80f953a244a099a40df1ff6aadc10ee375e6a098bbd5d55be332ce26db18c
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.2.1"
|
|
||||||
google_sign_in_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: google_sign_in_platform_interface
|
|
||||||
sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.1.0"
|
|
||||||
google_sign_in_web:
|
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: google_sign_in_web
|
|
||||||
sha256: "2fc1f941e6443b2d6984f4056a727a3eaeab15d8ee99ba7125d79029be75a1da"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.0"
|
|
||||||
graphs:
|
graphs:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -728,6 +680,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.3"
|
version: "2.2.3"
|
||||||
|
rename_app:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description:
|
||||||
|
name: rename_app
|
||||||
|
sha256: "9df79b531a59124f3ec60316385809f79835eccf934a1ca7051f602a988acade"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.6.6"
|
||||||
share_plus:
|
share_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -53,12 +53,10 @@ dependencies:
|
|||||||
share_plus: ^11.0.0
|
share_plus: ^11.0.0
|
||||||
flutter_contacts: ^1.1.9+2
|
flutter_contacts: ^1.1.9+2
|
||||||
gif_view: ^0.4.0
|
gif_view: ^0.4.0
|
||||||
google_sign_in: ^7.1.1
|
|
||||||
firebase_core: ^4.1.0
|
firebase_core: ^4.1.0
|
||||||
url_launcher: ^6.3.2
|
url_launcher: ^6.3.2
|
||||||
html: ^0.15.6
|
html: ^0.15.6
|
||||||
web: ^1.1.1
|
web: ^1.1.1
|
||||||
google_sign_in_web: ^1.1.0
|
|
||||||
flutter_web_plugins:
|
flutter_web_plugins:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
decimal: ^3.2.4
|
decimal: ^3.2.4
|
||||||
@@ -77,6 +75,7 @@ dev_dependencies:
|
|||||||
build_runner: ^2.5.4
|
build_runner: ^2.5.4
|
||||||
freezed: ^3.0.6
|
freezed: ^3.0.6
|
||||||
json_serializable: ^6.9.5
|
json_serializable: ^6.9.5
|
||||||
|
rename_app: ^1.6.6
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
@@ -128,3 +127,6 @@ icons_launcher:
|
|||||||
enable: true
|
enable: true
|
||||||
web:
|
web:
|
||||||
enable: true
|
enable: true
|
||||||
|
|
||||||
|
config_name:
|
||||||
|
name: "Velocity Pay"
|
||||||
|
|||||||
BIN
upload-keystore.jks
Normal file
BIN
upload-keystore.jks
Normal file
Binary file not shown.
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "Velocity",
|
"name": "Velocity Pay",
|
||||||
"short_name": "Velocity",
|
"short_name": "Velocity Pay",
|
||||||
"start_url": ".",
|
"start_url": ".",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"background_color": "#ffffff",
|
"background_color": "#ffffff",
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
|||||||
FlutterWindow window(project);
|
FlutterWindow window(project);
|
||||||
Win32Window::Point origin(10, 10);
|
Win32Window::Point origin(10, 10);
|
||||||
Win32Window::Size size(1280, 720);
|
Win32Window::Size size(1280, 720);
|
||||||
if (!window.Create(L"qpay", origin, size)) {
|
if (!window.Create(L"Velocity Pay", origin, size)) {
|
||||||
return EXIT_FAILURE;
|
return EXIT_FAILURE;
|
||||||
}
|
}
|
||||||
window.SetQuitOnClose(true);
|
window.SetQuitOnClose(true);
|
||||||
|
|||||||
Reference in New Issue
Block a user