completed group batch feature

This commit is contained in:
2026-06-08 09:17:36 +02:00
parent 2dd790fe6e
commit fc616d6316
46 changed files with 4610 additions and 2669 deletions

View File

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

View File

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

View File

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

BIN
assets/complete.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

BIN
assets/signup.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

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

View File

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

87
lib/auth_state.dart Normal file
View File

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

View File

@@ -1,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(),
),
],
),
],
),
); );
} }
} }

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:url_launcher/url_launcher.dart';
import 'models/responsive_policy.dart'; import 'models/responsive_policy.dart';
@@ -43,22 +44,43 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
body: SafeArea( body: SafeArea(
child: Row( child: Row(
children: [ children: [
NavigationRail( SizedBox(
extended: true, width: 256,
leading: Image( child: Column(
image: AssetImage('assets/velocity.png'), children: [
width: 150, Expanded(
child: NavigationRail(
extended: true,
leading: Padding(
padding: const EdgeInsets.symmetric(
vertical: 12.0,
),
child: Image(
image: AssetImage('assets/velocity.png'),
width: 150,
),
),
labelType: NavigationRailLabelType.none,
indicatorColor: Theme.of(
context,
).colorScheme.primary.withAlpha(30),
onDestinationSelected: (index) {
_handleTap(index, context);
},
selectedIndex: _calculateSelectedIndex(context),
destinations: _buildNavigationRailDestinations(),
),
),
Divider(
height: 1,
thickness: 1,
color: Theme.of(
context,
).colorScheme.primary.withAlpha(30),
),
_buildFooterLinks(context),
],
), ),
trailing: _buildLogin(context),
labelType: NavigationRailLabelType.none,
indicatorColor: Theme.of(
context,
).colorScheme.primary.withAlpha(30),
onDestinationSelected: (index) {
_handleTap(index, context);
},
selectedIndex: _calculateSelectedIndex(context),
destinations: _buildNavigationRailDestinations(),
), ),
VerticalDivider( VerticalDivider(
thickness: 1, thickness: 1,
@@ -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
View File

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

View File

@@ -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,
),
);
}); });
} }

View File

@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:qpay/models/responsive_policy.dart'; import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/confirm/confirm_controller.dart'; import 'package:qpay/screens/confirm/confirm_controller.dart';
import 'package:qpay/widgets/app_snack_bar.dart';
import 'package:skeletonizer/skeletonizer.dart'; import 'package:skeletonizer/skeletonizer.dart';
class ConfirmScreen extends StatefulWidget { class ConfirmScreen extends StatefulWidget {
@@ -65,17 +66,11 @@ class _ConfirmScreenState extends State<ConfirmScreen>
final response = await controller.doTransaction(); final response = await controller.doTransaction();
if (!response.isSuccess) { if (!response.isSuccess) {
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( AppSnackBar.showError(
SnackBar( context,
content: Text( controller.model.errorMessage ??
controller.model.errorMessage ?? response.error ??
response.error ?? "Transaction failed",
"Transaction failed",
),
behavior: SnackBarBehavior.floating,
width: 600,
backgroundColor: Colors.deepOrange,
),
); );
} }
return; return;

View File

@@ -3,6 +3,7 @@ import 'dart:async';
import 'package:qpay/http/http.dart'; import 'package:qpay/http/http.dart';
import 'package:qpay/models/api_response.dart'; import 'package:qpay/models/api_response.dart';
import 'package:qpay/screens/transaction_controller.dart'; import 'package:qpay/screens/transaction_controller.dart';
import 'package:qpay/widgets/app_snack_bar.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class GatewayModel { class GatewayModel {
@@ -30,13 +31,7 @@ class GatewayController extends ChangeNotifier {
void _showErrorSnackBar(String? message) { void _showErrorSnackBar(String? message) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar( AppSnackBar.showError(context, message.toString());
SnackBar(
content: Text(message.toString()),
behavior: SnackBarBehavior.floating,
backgroundColor: Colors.deepOrange,
),
);
}); });
} }

View File

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

View File

@@ -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);
},
);
}
} }

View File

@@ -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,15 +176,281 @@ 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 {
final batch = _batch; final batch = _batch;
if (batch == null) return; if (batch == null) return;
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;
@@ -538,7 +833,14 @@ 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: [ children: [
_buildStatusBadge(status), Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_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,

View File

@@ -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.',
); );
} }
} }

View File

@@ -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,140 +87,631 @@ 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,
expand: false, maxChildSize: 0.92,
builder: (context, scrollController) { expand: false,
return Padding( builder: (context, scrollController) {
padding: const EdgeInsets.all(16), return StatefulBuilder(
child: Column( builder: (context, setSheetState) {
crossAxisAlignment: CrossAxisAlignment.start, return Column(
children: [ children: [
Row( _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(
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(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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 Spacer(), const SizedBox(height: 2),
IconButton( Text(
icon: const Icon(Icons.close), '$total ${total == 1 ? "member" : "members"} in this group',
onPressed: () => Navigator.of(context).pop(), style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
), ),
], ],
), ),
const SizedBox(height: 4), ),
Text( IconButton(
'${groupController.model.members.length} member(s)', icon: Icon(Icons.close, color: Colors.grey.shade700),
style: TextStyle(fontSize: 14, color: Colors.grey.shade600), tooltip: 'Close',
onPressed: () => Navigator.of(context).pop(),
),
],
),
if (total > 0) ...[
const SizedBox(height: 14),
TextFormField(
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,
if (groupController.model.members.isEmpty) { size: 18,
return Center( ),
child: Padding( onPressed: () {
padding: const EdgeInsets.all(32), _memberSearchController.clear();
child: Column( setSheetState(() => _memberQuery = '');
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.person_outline,
size: 48,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
const Text(
'No members found.',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
return ListView.separated(
controller: scrollController,
itemCount: groupController.model.members.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final member = groupController.model.members[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(
vertical: 2,
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,
);
}, },
); )
}, : 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,
), ),
), ),
], ),
), ),
); ],
}, ],
), ),
); );
} }
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(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.search_off_outlined,
size: 56,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
const Text(
'No matching members',
style: TextStyle(
fontSize: 16,
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,
),
),
],
),
),
);
}
String _initialsFor(String name) {
final cleaned = name.trim();
if (cleaned.isEmpty) return '?';
final parts = cleaned.split(RegExp(r'\s+'));
if (parts.length == 1) {
return parts.first.characters.first.toUpperCase();
}
return (parts.first.characters.first + parts.last.characters.first)
.toUpperCase();
}
void _onDeleteSelected() async { void _onDeleteSelected() async {
final userId = widget.group.userId ?? ''; final userId = widget.group.userId ?? '';
final count = batchController.model.selectedBatchIds.length; final count = batchController.model.selectedBatchIds.length;
@@ -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',

View File

@@ -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',

View File

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

View File

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

View File

@@ -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);

View File

@@ -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,69 +210,21 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
child: CustomScrollView( child: CustomScrollView(
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
slivers: [ slivers: [
SliverAppBar( if (isMobile)
pinned: false, SliverAppBar(
floating: true, pinned: false,
stretch: true, floating: true,
surfaceTintColor: Colors.transparent, stretch: true,
backgroundColor: isDark surfaceTintColor: Colors.transparent,
? const Color(0xFF0D0D0D) backgroundColor: isDark
: const Color(0xFFF8F9FA), ? const Color(0xFF0D0D0D)
title: constraints.maxWidth < ResponsivePolicy.md : const Color(0xFFF8F9FA),
? Image( title: Image(
image: const AssetImage('assets/velocity.png'), image: const AssetImage('assets/velocity.png'),
width: 130, width: 130,
) ),
: null, actions: _buildAppBarActions(theme, isDark),
actions: [ ),
if (_isLoggedIn) ...[
Container(
width: 30,
height: 30,
margin: const EdgeInsets.only(right: 4),
decoration: BoxDecoration(
border: Border.all(
color: isDark
? Colors.white.withValues(alpha: 0.12)
: Colors.black87.withAlpha(20),
),
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [
theme.colorScheme.primary.withValues(
alpha: 0.15,
),
theme.colorScheme.tertiary.withValues(
alpha: 0.05,
),
],
stops: const [0.3, 0.7],
begin: Alignment.topRight,
end: Alignment.bottomLeft,
),
),
child: Center(
child: Text(
initials,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : Colors.black87,
),
),
),
),
IconButton(
icon: Icon(
Icons.logout_rounded,
color: isDark ? Colors.white54 : Colors.black54,
size: 20,
),
onPressed: _showLogoutDialog,
),
],
],
),
SliverToBoxAdapter( SliverToBoxAdapter(
child: Center( child: Center(
child: SizedBox( child: SizedBox(
@@ -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( 'ZWG currency not supported yet',
child: const Text( customContent: const Center(
'ZWG currency not supported yet', child: Text('ZWG currency not supported yet'),
),
),
behavior: SnackBarBehavior.floating,
backgroundColor: theme.colorScheme.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
duration: const Duration(seconds: 3),
width: 300,
), ),
backgroundColor: theme.colorScheme.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
duration: const Duration(seconds: 3),
); );
} }
@@ -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,
),
),
],
),
),
],
);
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

BIN
upload-keystore.jks Normal file

Binary file not shown.

View File

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

View File

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