completed group batch feature
This commit is contained in:
7
No file path - using task_progress only
Normal file
7
No file path - using task_progress only
Normal file
@@ -0,0 +1,7 @@
|
||||
- [ ] Analyze history_controller and add support for filter (status, type, date) + bulk select
|
||||
- [ ] Update history_screen to mirror group detail list layout (search + filter + select + refresh + new buttons)
|
||||
- [ ] Add filter bottom sheet for transactions (status/type)
|
||||
- [ ] Add bulk action bar (Repeat selected, Delete selected) when select mode is on
|
||||
- [ ] Add scroll-to-load-more pagination like group detail
|
||||
- [ ] Tone down animation (short, subtle fade/slide)
|
||||
- [ ] Verify compile with `flutter analyze`
|
||||
@@ -1,3 +1,6 @@
|
||||
import java.util.Properties
|
||||
import java.io.FileInputStream
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
// START: FlutterFire Configuration
|
||||
@@ -8,6 +11,14 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
|
||||
val keystoreProperties = Properties()
|
||||
val keystorePropertiesFile = rootProject.file("key.properties")
|
||||
if (keystorePropertiesFile.exists()) {
|
||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||
}
|
||||
|
||||
|
||||
android {
|
||||
namespace = "zw.co.qantra.qpay"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
@@ -34,11 +45,21 @@ android {
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
// signingConfig = signingConfigs.getByName("debug")
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,30 +2,15 @@
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.READ_CONTACTS"/>
|
||||
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
|
||||
<application
|
||||
android:label="Velocity"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:forceDarkAllowed="false">
|
||||
<application android:label="Velocity Pay" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" android:forceDarkAllowed="false">
|
||||
|
||||
<!-- Main Activity -->
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<activity android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" android:taskAffinity="" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" android:windowSoftInputMode="adjustResize">
|
||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
||||
the Android process has started. This theme is visible to the user
|
||||
while the Flutter UI initializes. After that, this theme continues
|
||||
to determine the Window background behind the Flutter UI. -->
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<meta-data android:name="io.flutter.embedding.android.NormalTheme" android:resource="@style/NormalTheme"/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
@@ -33,9 +18,7 @@
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
<meta-data android:name="flutterEmbedding" android:value="2"/>
|
||||
</application>
|
||||
<!-- Required to query activities that can process text, see:
|
||||
https://developer.android.com/training/package-visibility and
|
||||
|
||||
BIN
assets/complete.png
Normal file
BIN
assets/complete.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
BIN
assets/signup.png
Normal file
BIN
assets/signup.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
@@ -5,7 +5,7 @@
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Velocity</string>
|
||||
<string>Velocity Pay</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
@@ -13,7 +13,7 @@
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Velocity</string>
|
||||
<string>Velocity Pay</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
@@ -59,6 +59,5 @@
|
||||
<string>To fetch recipient phone numbers</string>
|
||||
<key>UIUserInterfaceStyle</key>
|
||||
<string>Light</string>
|
||||
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,16 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||
|
||||
class AbstractController extends ChangeNotifier {
|
||||
late BuildContext context;
|
||||
void showErrorSnackBar(String? message) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message.toString()),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Colors.deepOrange,
|
||||
),
|
||||
);
|
||||
AppSnackBar.showError(context, message.toString());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
87
lib/auth_state.dart
Normal file
87
lib/auth_state.dart
Normal file
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Tracks the user's authentication state so the [GoRouter] can decide
|
||||
/// whether to allow access to protected routes (such as the groups
|
||||
/// flow) or redirect to the sign-in screen.
|
||||
///
|
||||
/// The router is wired to refresh whenever [notifyListeners] is called,
|
||||
/// which means any place that mutates the auth state (login, logout,
|
||||
/// registration completion) just has to call [refresh] to keep the
|
||||
/// navigation layer in sync.
|
||||
class AuthState extends ChangeNotifier {
|
||||
/// SharedPreferences key used to persist the auth token returned by
|
||||
/// the backend after a successful sign-in.
|
||||
static const String _tokenKey = 'token';
|
||||
|
||||
bool _isLoggedIn = false;
|
||||
bool _initialized = false;
|
||||
|
||||
/// The route the user originally tried to visit before being sent to
|
||||
/// the sign-in screen. After a successful login, the redirect logic
|
||||
/// reads and clears this so the user lands where they were heading.
|
||||
String? _intendedRoute;
|
||||
|
||||
bool get isLoggedIn => _isLoggedIn;
|
||||
|
||||
String? get intendedRoute => _intendedRoute;
|
||||
|
||||
/// Loads the current auth state from disk. Safe to call multiple
|
||||
/// times; subsequent calls just re-read the token value.
|
||||
Future<void> initialize() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_update(prefs.getString(_tokenKey) != null);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Re-reads the auth state from disk and notifies listeners if it
|
||||
/// has changed. Call this from places that mutate the persisted
|
||||
/// token (e.g. after the login API call or after the user logs out).
|
||||
Future<void> refresh() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_update(prefs.getString(_tokenKey) != null);
|
||||
}
|
||||
|
||||
void _update(bool loggedIn) {
|
||||
if (_isLoggedIn == loggedIn) return;
|
||||
_isLoggedIn = loggedIn;
|
||||
// NOTE: we intentionally do NOT clear _intendedRoute here. The
|
||||
// router's redirect callback reads it on the next build frame via
|
||||
// [consumeIntendedRoute], which is responsible for clearing it
|
||||
// after it has been consumed.
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Called by the router's redirect callback when a protected route
|
||||
/// is hit by an unauthenticated user. Stores the route the user was
|
||||
/// trying to visit and returns the sign-in path the router should
|
||||
/// navigate to.
|
||||
String? requireAuth(String attemptedRoute) {
|
||||
if (_isLoggedIn) return null;
|
||||
// Avoid stacking redirects when the user is already on the
|
||||
// sign-in / sign-up screens.
|
||||
if (attemptedRoute.startsWith('/onboarding/')) return null;
|
||||
_intendedRoute = attemptedRoute;
|
||||
return '/onboarding/landing';
|
||||
}
|
||||
|
||||
/// Returns the previously captured intended route so the router can
|
||||
/// navigate the user there after a successful sign-in. The value is
|
||||
/// consumed (cleared) once read to avoid loops.
|
||||
String? consumeIntendedRoute() {
|
||||
if (!_isLoggedIn) return null;
|
||||
final route = _intendedRoute;
|
||||
_intendedRoute = null;
|
||||
return route;
|
||||
}
|
||||
}
|
||||
|
||||
/// Global singleton so widgets and the router can share the same
|
||||
/// instance without needing to plumb a provider through the app.
|
||||
final AuthState authState = AuthState();
|
||||
|
||||
/// Whether the auth state has finished its first read from disk.
|
||||
/// The router waits for this before evaluating auth-gated redirects
|
||||
/// so we don't briefly bounce users to the sign-in screen on cold
|
||||
/// start before their persisted token has been loaded.
|
||||
bool get isAuthInitialized => authState._initialized;
|
||||
253
lib/main.dart
253
lib/main.dart
@@ -1,38 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qpay/navbar.dart';
|
||||
import 'package:qpay/screens/confirm/confirm_screen.dart';
|
||||
import 'package:qpay/screens/gateway/gateway_redirect.dart';
|
||||
import 'package:qpay/screens/gateway/gateway_screen.dart';
|
||||
import 'package:qpay/screens/gateway/gateway_web_screen.dart';
|
||||
import 'package:qpay/screens/home/home_screen.dart';
|
||||
import 'package:qpay/screens/history/history_screen.dart';
|
||||
import 'package:qpay/screens/integration/integration_screen.dart';
|
||||
import 'package:qpay/screens/onboarding/complete_screen.dart';
|
||||
import 'package:qpay/screens/onboarding/landing/landing_screen.dart';
|
||||
import 'package:qpay/screens/onboarding/login/login_screen.dart';
|
||||
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
|
||||
import 'package:qpay/screens/onboarding/password/password_screen.dart';
|
||||
import 'package:qpay/screens/onboarding/phone/phone_screen.dart';
|
||||
import 'package:qpay/screens/onboarding/register/register_screen.dart';
|
||||
import 'package:qpay/screens/onboarding/verify/verify_screen.dart';
|
||||
import 'package:qpay/screens/pay/pay_screen.dart';
|
||||
import 'package:qpay/screens/poll/poll_screen.dart';
|
||||
import 'package:qpay/screens/splash_screen.dart';
|
||||
import 'package:qpay/screens/receipt/receipt_screen.dart';
|
||||
import 'package:qpay/screens/recipient/recipients_screen.dart';
|
||||
import 'package:qpay/auth_state.dart';
|
||||
import 'package:qpay/routes.dart';
|
||||
import 'package:qpay/screens/transaction_controller.dart';
|
||||
import 'package:qpay/screens/profile_screen.dart';
|
||||
import 'package:qpay/screens/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/screens/onboarding/onboarding_controller.dart';
|
||||
import 'package:qpay/theme/app_theme.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:firebase_core/firebase_core.dart';
|
||||
@@ -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 {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
usePathUrlStrategy();
|
||||
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||
|
||||
await dotenv.load(fileName: resolveEnvFile(appEnv));
|
||||
|
||||
// Read the persisted auth token before the first frame so the router
|
||||
// knows whether the user is already signed in on cold start. Without
|
||||
// this we would briefly treat logged-in users as guests and bounce
|
||||
// them to the sign-in screen until the first refresh kicked in.
|
||||
await authState.initialize();
|
||||
|
||||
runApp(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
@@ -119,186 +67,7 @@ class _MyAppState extends State<MyApp> {
|
||||
theme: AppTheme.lightTheme,
|
||||
themeMode: ThemeMode.light,
|
||||
debugShowCheckedModeBanner: false,
|
||||
routerConfig: GoRouter(
|
||||
initialLocation: '/',
|
||||
refreshListenable: splashState,
|
||||
errorBuilder: (context, state) => Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('Page not found', style: TextStyle(fontSize: 20)),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.go('/'),
|
||||
child: const Text('Go Home'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
redirect: (context, state) {
|
||||
// Before splash is complete: redirect everything to /splash,
|
||||
// but save the original intended destination.
|
||||
final guardResult = splashState.guard(state.uri.path);
|
||||
if (guardResult != null) return guardResult;
|
||||
|
||||
// Once splash is complete: if we're still on /splash, navigate
|
||||
// to the intended destination (or home as fallback).
|
||||
if (splashState.complete && state.uri.path == '/splash') {
|
||||
return splashState.intendedRoute ?? '/';
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
// Splash route sits outside the ShellRoute so it renders
|
||||
// without the bottom nav bar or side rail.
|
||||
GoRoute(
|
||||
path: '/splash',
|
||||
builder: (context, state) => const SplashScreen(),
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) {
|
||||
final location = GoRouterState.of(context).uri.toString();
|
||||
if (location.startsWith('/onboarding/')) {
|
||||
return child;
|
||||
}
|
||||
return ScaffoldWithNavBar(child: child);
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
builder: (context, state) => const HomeScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/home',
|
||||
builder: (context, state) => const HomeScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/make-payment',
|
||||
builder: (context, state) => const PayScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/confirm',
|
||||
builder: (context, state) => const ConfirmScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/gateway',
|
||||
builder: (context, state) => const GatewayScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/gateway-web',
|
||||
builder: (context, state) => const GatewayWebScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/gateway-redirect',
|
||||
builder: (context, state) => const GatewayRedirectScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/poll/:id',
|
||||
builder: (context, state) =>
|
||||
PollScreen(transactionId: state.pathParameters['id']),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/poll',
|
||||
builder: (context, state) => const PollScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/receipt',
|
||||
builder: (context, state) => const ReceiptScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/receipt/:transactionId',
|
||||
builder: (context, state) => ReceiptScreen(
|
||||
transactionId: state.pathParameters['transactionId'],
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/integration',
|
||||
builder: (context, state) => const IntegrationScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/recipients',
|
||||
builder: (context, state) => const RecipientsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/history',
|
||||
builder: (context, state) => const HistoryScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/profile',
|
||||
builder: (context, state) => const ProfileScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
routerConfig: buildRouter(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
168
lib/navbar.dart
168
lib/navbar.dart
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import 'models/responsive_policy.dart';
|
||||
|
||||
@@ -43,13 +44,22 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
||||
body: SafeArea(
|
||||
child: Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
SizedBox(
|
||||
width: 256,
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: NavigationRail(
|
||||
extended: true,
|
||||
leading: Image(
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12.0,
|
||||
),
|
||||
child: Image(
|
||||
image: AssetImage('assets/velocity.png'),
|
||||
width: 150,
|
||||
),
|
||||
trailing: _buildLogin(context),
|
||||
),
|
||||
labelType: NavigationRailLabelType.none,
|
||||
indicatorColor: Theme.of(
|
||||
context,
|
||||
@@ -60,6 +70,18 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
||||
selectedIndex: _calculateSelectedIndex(context),
|
||||
destinations: _buildNavigationRailDestinations(),
|
||||
),
|
||||
),
|
||||
Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withAlpha(30),
|
||||
),
|
||||
_buildFooterLinks(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
VerticalDivider(
|
||||
thickness: 1,
|
||||
width: 1,
|
||||
@@ -134,56 +156,6 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildLogin(BuildContext context) {
|
||||
if (_isLoggedIn) {
|
||||
return SizedBox();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 220,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.black87.withAlpha(20)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: EdgeInsets.symmetric(vertical: 10, horizontal: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: 10,
|
||||
children: [
|
||||
Text(
|
||||
'Register or Login for more features',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
context.push('/onboarding/landing');
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text('Get Started', style: TextStyle(fontSize: 14)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleTap(int index, BuildContext context) {
|
||||
switch (index) {
|
||||
case 0:
|
||||
@@ -204,4 +176,94 @@ class _ScaffoldWithNavBarState extends State<ScaffoldWithNavBar> {
|
||||
if (location.startsWith('/history')) return 2;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// Footer link entries shown at the bottom of the web navigation rail.
|
||||
/// Each entry pairs the visible label with the external URL it should
|
||||
/// open when tapped.
|
||||
static const List<Map<String, String>> _footerLinkEntries = [
|
||||
{'label': 'About', 'url': 'https://velocityafrica.net'},
|
||||
{'label': 'FAQs', 'url': 'https://velocityafrica.net'},
|
||||
{'label': 'Privacy', 'url': 'https://qantra.co.zw/privacy'},
|
||||
{'label': 'Terms', 'url': 'https://qantra.co.zw/terms'},
|
||||
];
|
||||
|
||||
/// Opens the supplied [url] in an external browser using
|
||||
/// `url_launcher`. Errors are swallowed because the navbar footer
|
||||
/// is non-critical UI.
|
||||
Future<void> _openExternalLink(String url) async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null) return;
|
||||
try {
|
||||
final launched = await launchUrl(
|
||||
uri,
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
if (!launched && mounted) {
|
||||
await launchUrl(uri, mode: LaunchMode.platformDefault);
|
||||
}
|
||||
} catch (_) {
|
||||
// Intentionally silent — footer links are best-effort.
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the footer section at the bottom of the web navigation rail.
|
||||
/// Renders the About / FAQs / Privacy / Terms links (each opening
|
||||
/// the configured external URL) followed by a copyright line.
|
||||
Widget _buildFooterLinks(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
final mutedTextColor = isDark ? Colors.white60 : Colors.black54;
|
||||
final linkTextColor = isDark ? Colors.white : Colors.black87;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
for (var i = 0; i < _footerLinkEntries.length; i++) ...[
|
||||
InkWell(
|
||||
onTap: () => _openExternalLink(_footerLinkEntries[i]['url']!),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 2,
|
||||
),
|
||||
child: Text(
|
||||
_footerLinkEntries[i]['label']!,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: linkTextColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (i != _footerLinkEntries.length - 1)
|
||||
Text(
|
||||
'·',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: mutedTextColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'© 2026 Velocity Africa. All rights reserved.',
|
||||
style: TextStyle(fontSize: 10, color: mutedTextColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
282
lib/routes.dart
Normal file
282
lib/routes.dart
Normal file
@@ -0,0 +1,282 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qpay/auth_state.dart';
|
||||
import 'package:qpay/navbar.dart';
|
||||
import 'package:qpay/screens/confirm/confirm_screen.dart';
|
||||
import 'package:qpay/screens/gateway/gateway_redirect.dart';
|
||||
import 'package:qpay/screens/gateway/gateway_screen.dart';
|
||||
import 'package:qpay/screens/gateway/gateway_web_screen.dart';
|
||||
import 'package:qpay/screens/groups/batch_create_screen.dart';
|
||||
import 'package:qpay/screens/groups/batch_detail_screen.dart';
|
||||
import 'package:qpay/screens/groups/batch_item_screen.dart';
|
||||
import 'package:qpay/screens/groups/group_create_screen.dart';
|
||||
import 'package:qpay/screens/groups/group_detail_screen.dart';
|
||||
import 'package:qpay/screens/groups/groups_screen.dart';
|
||||
import 'package:qpay/screens/groups/models/group_batch_model.dart';
|
||||
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
|
||||
import 'package:qpay/screens/history/history_screen.dart';
|
||||
import 'package:qpay/screens/home/home_screen.dart';
|
||||
import 'package:qpay/screens/integration/integration_screen.dart';
|
||||
import 'package:qpay/screens/onboarding/complete_screen.dart';
|
||||
import 'package:qpay/screens/onboarding/landing/landing_screen.dart';
|
||||
import 'package:qpay/screens/onboarding/login/login_screen.dart';
|
||||
import 'package:qpay/screens/onboarding/phone/phone_screen.dart';
|
||||
import 'package:qpay/screens/onboarding/verify/verify_screen.dart';
|
||||
import 'package:qpay/screens/pay/pay_screen.dart';
|
||||
import 'package:qpay/screens/poll/poll_screen.dart';
|
||||
import 'package:qpay/screens/profile_screen.dart';
|
||||
import 'package:qpay/screens/receipt/receipt_screen.dart';
|
||||
import 'package:qpay/screens/recipient/recipients_screen.dart';
|
||||
import 'package:qpay/screens/splash_screen.dart';
|
||||
|
||||
/// Tracks whether the splash animation is complete so that
|
||||
/// GoRouter's redirect can show the splash first, then release
|
||||
/// navigation to the originally intended URL.
|
||||
class SplashState extends ChangeNotifier {
|
||||
bool _complete = false;
|
||||
|
||||
/// The route the user originally intended to visit before being
|
||||
/// redirected to /splash. Set by the redirect callback on first
|
||||
/// invocation.
|
||||
String? _intendedRoute;
|
||||
bool get complete => _complete;
|
||||
String? get intendedRoute => _intendedRoute;
|
||||
|
||||
/// Called by the redirect to store the intended destination and
|
||||
/// return the splash route.
|
||||
String? guard(String attemptedRoute) {
|
||||
if (_complete) return null;
|
||||
// Avoid saving the splash route itself.
|
||||
if (attemptedRoute == '/splash') return null;
|
||||
_intendedRoute = attemptedRoute;
|
||||
return '/splash';
|
||||
}
|
||||
|
||||
void finish() {
|
||||
_complete = true;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
final SplashState splashState = SplashState();
|
||||
|
||||
/// Route prefixes that require the user to be signed in. Any URL
|
||||
/// whose path starts with one of these strings will be redirected
|
||||
/// to the sign-in screen when no auth token is present.
|
||||
const List<String> _authProtectedRoutePrefixes = ['/groups'];
|
||||
|
||||
bool _isAuthProtected(String path) {
|
||||
for (final prefix in _authProtectedRoutePrefixes) {
|
||||
if (path == prefix || path.startsWith('$prefix/')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Builds the application's [GoRouter] instance.
|
||||
///
|
||||
/// The router is responsible for:
|
||||
/// - Showing the splash screen on cold start and replaying the
|
||||
/// originally intended route when the animation finishes.
|
||||
/// - Hosting the [ShellRoute] that adds the [ScaffoldWithNavBar]
|
||||
/// to every authenticated screen (the onboarding flow renders
|
||||
/// without the shell so the UI stays full-bleed).
|
||||
/// - Enforcing the auth wall around protected routes (currently the
|
||||
/// groups flow). Unauthenticated users are bounced to the sign-in
|
||||
/// screen, then sent back to their original destination after a
|
||||
/// successful sign-in.
|
||||
GoRouter buildRouter() {
|
||||
return GoRouter(
|
||||
initialLocation: '/',
|
||||
// React to both the splash completion and auth state changes so
|
||||
// protected routes are re-evaluated as the user signs in or out.
|
||||
refreshListenable: Listenable.merge([splashState, authState]),
|
||||
errorBuilder: (context, state) => Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Text('Page not found', style: TextStyle(fontSize: 20)),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.go('/'),
|
||||
child: const Text('Go Home'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
redirect: (context, state) {
|
||||
// Before splash is complete: redirect everything to /splash,
|
||||
// but save the original intended destination.
|
||||
final guardResult = splashState.guard(state.uri.path);
|
||||
if (guardResult != null) return guardResult;
|
||||
|
||||
// Once splash is complete we can start evaluating auth rules.
|
||||
// We also wait for the initial auth state read to complete so
|
||||
// a persisted token isn't accidentally ignored on cold start.
|
||||
if (splashState.complete && isAuthInitialized) {
|
||||
final path = state.uri.path;
|
||||
|
||||
// If the user has just signed in and we still have a pending
|
||||
// intended route, take them there instead of leaving them
|
||||
// stuck on the sign-in screen.
|
||||
if (path == '/onboarding/login' && authState.isLoggedIn) {
|
||||
final intended = authState.consumeIntendedRoute();
|
||||
if (intended != null && intended != '/onboarding/login') {
|
||||
return intended;
|
||||
}
|
||||
}
|
||||
|
||||
// Auth wall: any protected route requires a valid token.
|
||||
if (_isAuthProtected(path) && !authState.isLoggedIn) {
|
||||
return authState.requireAuth(path);
|
||||
}
|
||||
|
||||
// Splash → original route or home.
|
||||
if (path == '/splash') {
|
||||
return splashState.intendedRoute ?? '/';
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
// Splash route sits outside the ShellRoute so it renders
|
||||
// without the bottom nav bar or side rail.
|
||||
GoRoute(
|
||||
path: '/splash',
|
||||
builder: (context, state) => const SplashScreen(),
|
||||
),
|
||||
ShellRoute(
|
||||
builder: (context, state, child) {
|
||||
final location = GoRouterState.of(context).uri.toString();
|
||||
if (location.startsWith('/onboarding/')) {
|
||||
return child;
|
||||
}
|
||||
return ScaffoldWithNavBar(child: child);
|
||||
},
|
||||
routes: [
|
||||
GoRoute(path: '/', builder: (context, state) => const HomeScreen()),
|
||||
GoRoute(
|
||||
path: '/home',
|
||||
builder: (context, state) => const HomeScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/make-payment',
|
||||
builder: (context, state) => const PayScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/confirm',
|
||||
builder: (context, state) => const ConfirmScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/gateway',
|
||||
builder: (context, state) => const GatewayScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/gateway-web',
|
||||
builder: (context, state) => const GatewayWebScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/gateway-redirect',
|
||||
builder: (context, state) => const GatewayRedirectScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/poll/:id',
|
||||
builder: (context, state) =>
|
||||
PollScreen(transactionId: state.pathParameters['id']),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/poll',
|
||||
builder: (context, state) => const PollScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/receipt',
|
||||
builder: (context, state) => const ReceiptScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/receipt/:transactionId',
|
||||
builder: (context, state) => ReceiptScreen(
|
||||
transactionId: state.pathParameters['transactionId'],
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/integration',
|
||||
builder: (context, state) => const IntegrationScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/recipients',
|
||||
builder: (context, state) => const RecipientsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/history',
|
||||
builder: (context, state) => const HistoryScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/profile',
|
||||
builder: (context, state) => const ProfileScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/groups',
|
||||
builder: (context, state) => const GroupsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/groups/create',
|
||||
builder: (context, state) => const GroupCreateScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/groups/:groupId',
|
||||
builder: (context, state) {
|
||||
final group = state.extra as RecipientGroup;
|
||||
return GroupDetailScreen(group: group);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/groups/:groupId/batches/create',
|
||||
builder: (context, state) {
|
||||
final group = state.extra as RecipientGroup;
|
||||
return BatchCreateScreen(group: group);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/groups/:groupId/batches/:batchId',
|
||||
builder: (context, state) {
|
||||
final batchId = state.pathParameters['batchId']!;
|
||||
final groupId = state.pathParameters['groupId']!;
|
||||
return BatchDetailScreen(batchId: batchId, groupId: groupId);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/groups/:groupId/batches/:batchId/items/:itemId',
|
||||
builder: (context, state) {
|
||||
final item = state.extra as GroupBatchItem;
|
||||
return BatchItemScreen(item: item);
|
||||
},
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding/landing',
|
||||
builder: (context, state) => const LandingScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding/phone',
|
||||
builder: (context, state) => const PhoneScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding/verify',
|
||||
builder: (context, state) => const VerifyScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding/login',
|
||||
builder: (context, state) => const LoginScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/onboarding/complete',
|
||||
builder: (context, state) => const CompleteScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qpay/models/api_response.dart';
|
||||
import 'package:qpay/screens/transaction_controller.dart';
|
||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||
|
||||
import '../../http/http.dart';
|
||||
|
||||
@@ -43,13 +44,7 @@ class ConfirmController extends ChangeNotifier {
|
||||
|
||||
void _showErrorSnackBar(String? message) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message.toString()),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Colors.deepOrange,
|
||||
),
|
||||
);
|
||||
AppSnackBar.showError(context, message.toString());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/confirm/confirm_controller.dart';
|
||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
|
||||
class ConfirmScreen extends StatefulWidget {
|
||||
@@ -65,17 +66,11 @@ class _ConfirmScreenState extends State<ConfirmScreen>
|
||||
final response = await controller.doTransaction();
|
||||
if (!response.isSuccess) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppSnackBar.showError(
|
||||
context,
|
||||
controller.model.errorMessage ??
|
||||
response.error ??
|
||||
"Transaction failed",
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
width: 600,
|
||||
backgroundColor: Colors.deepOrange,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:async';
|
||||
import 'package:qpay/http/http.dart';
|
||||
import 'package:qpay/models/api_response.dart';
|
||||
import 'package:qpay/screens/transaction_controller.dart';
|
||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class GatewayModel {
|
||||
@@ -30,13 +31,7 @@ class GatewayController extends ChangeNotifier {
|
||||
|
||||
void _showErrorSnackBar(String? message) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(message.toString()),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Colors.deepOrange,
|
||||
),
|
||||
);
|
||||
AppSnackBar.showError(context, message.toString());
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qpay/screens/gateway/gateway_controller.dart';
|
||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
class GatewayScreen extends StatefulWidget {
|
||||
@@ -69,15 +70,11 @@ class _GatewayScreenState extends State<GatewayScreen> {
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
"Payment was successful, please wait while we process your order.",
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
AppSnackBar.show(
|
||||
context,
|
||||
'Payment was successful, please wait while we process your order.',
|
||||
backgroundColor: Colors.black87,
|
||||
duration: const Duration(seconds: 10),
|
||||
),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
|
||||
import 'package:qpay/screens/home/home_controller.dart';
|
||||
import 'package:qpay/screens/pay/pay_controller.dart';
|
||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class BatchCreateScreen extends StatefulWidget {
|
||||
@@ -57,17 +58,13 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
||||
final selectedProvider = controller.model.selectedProvider;
|
||||
|
||||
if (selectedProvider == null) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please select a bill provider.')),
|
||||
);
|
||||
AppSnackBar.show(context, 'Please select a bill provider.');
|
||||
return;
|
||||
}
|
||||
|
||||
final amount = double.tryParse(_amountController.text.trim());
|
||||
if (amount == null || amount <= 0) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Please enter a valid amount.')),
|
||||
);
|
||||
AppSnackBar.show(context, 'Please enter a valid amount.');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -106,8 +103,9 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
||||
final batch = result.data!;
|
||||
context.pushReplacement('/groups/${widget.group.id}/batches/${batch.id}');
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(result.error ?? 'Failed to create batch.')),
|
||||
AppSnackBar.showError(
|
||||
context,
|
||||
result.error ?? 'Failed to create batch.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -441,38 +439,4 @@ class _BatchCreateScreenState extends State<BatchCreateScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProcessorDropdown() {
|
||||
if (controller.model.processors.isEmpty) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.black26),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
'No processors available',
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
);
|
||||
}
|
||||
return DropdownButtonFormField<PaymentProcessor>(
|
||||
value: controller.model.selectedProcessor,
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
items: controller.model.processors
|
||||
.map(
|
||||
(p) => DropdownMenuItem(
|
||||
value: p,
|
||||
child: Text(p.name, overflow: TextOverflow.ellipsis),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: (p) {
|
||||
if (p != null) controller.selectProcessor(p);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:qpay/screens/groups/models/group_batch_model.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/groups/controllers/batch_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:shared_preferences/shared_preferences.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
@@ -147,11 +148,10 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||
final batch = _batch;
|
||||
if (batch == null) return;
|
||||
|
||||
final processor = _selectedProcessor;
|
||||
if (processor == null) {
|
||||
_showError('Please select a payment processor first.');
|
||||
return;
|
||||
}
|
||||
// Show the bottom sheet so the user can pick a processor and confirm.
|
||||
final processor = await _showProcessorSelectorBottomSheet();
|
||||
if (processor == null) return; // user dismissed
|
||||
_selectedProcessor = processor;
|
||||
|
||||
// Persist the selected processor on the batch before confirming
|
||||
final updateReq = GroupBatchUpdateRequest(
|
||||
@@ -162,10 +162,9 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||
paymentProcessorImage: processor.image,
|
||||
);
|
||||
final updateResult = await controller.updateBatch(updateReq);
|
||||
if (!mounted) return;
|
||||
if (!updateResult.isSuccess) {
|
||||
if (mounted) {
|
||||
_showError(updateResult.error ?? 'Failed to update processor.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -177,7 +176,271 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||
setState(() => _selectedProcessor = null);
|
||||
setState(() => _initialLoading = true);
|
||||
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 {
|
||||
@@ -186,6 +449,8 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||
final result = await controller.requestBatch(batch.id!, _userId);
|
||||
if (!mounted) return;
|
||||
|
||||
await _init(); // refresh batch and items after request
|
||||
|
||||
if (!result.isSuccess) {
|
||||
_showError(result.error ?? 'Request failed.');
|
||||
return;
|
||||
@@ -195,12 +460,10 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||
final pollingStatus = txn.pollingStatus?.toUpperCase();
|
||||
|
||||
if (pollingStatus != null && _terminalStatuses.contains(pollingStatus)) {
|
||||
await controller.listBatchItems(batch.id!);
|
||||
await _init(); // refresh batch and items after request
|
||||
if (_successStatuses.contains(pollingStatus)) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Payment completed successfully.')),
|
||||
);
|
||||
AppSnackBar.showSuccess(context, 'Payment completed successfully.');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -208,11 +471,12 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||
if (!mounted) return;
|
||||
if (pollResult.isSuccess) {
|
||||
setState(() => _showPollButton = false);
|
||||
await controller.listBatchItems(batch.id!);
|
||||
} else {
|
||||
setState(() => _showPollButton = true);
|
||||
_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 (result.isSuccess) {
|
||||
setState(() => _showPollButton = false);
|
||||
await controller.listBatchItems(batch.id!);
|
||||
await _init(); // refresh batch and items after poll
|
||||
} else {
|
||||
_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) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
AppSnackBar.showError(context, message);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
@@ -376,17 +646,6 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||
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),
|
||||
FadeTransition(
|
||||
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
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
@@ -505,7 +801,6 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||
final currency = batch.currency ?? 'USD';
|
||||
final value = batch.value;
|
||||
final status = batch.status ?? 'UNKNOWN';
|
||||
final processorName = batch.paymentProcessorName ?? '';
|
||||
final createdAt = batch.createdAt;
|
||||
final parsedDate = createdAt != null ? DateTime.tryParse(createdAt) : null;
|
||||
|
||||
@@ -537,8 +832,15 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildStatusBadge(status),
|
||||
const SizedBox(width: 8),
|
||||
_buildRefreshButton(theme, isDark),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
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),
|
||||
if (parsedDate != null)
|
||||
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
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
@@ -707,19 +897,17 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
|
||||
buttons.add(
|
||||
_modernActionButton(
|
||||
icon: Icons.check_circle_outline_rounded,
|
||||
label: 'Confirm',
|
||||
color: _selectedProcessor != null
|
||||
? theme.colorScheme.primary
|
||||
: Colors.grey,
|
||||
label: 'Confirm Items',
|
||||
color: theme.colorScheme.primary,
|
||||
isLoading: isActing,
|
||||
onPressed: _onConfirm,
|
||||
),
|
||||
);
|
||||
} else if (status == 'CONFIRMED_ALL') {
|
||||
} else if (status == 'CONFIRMED_ALL' || status == 'REQUEST_FAILED') {
|
||||
buttons.add(
|
||||
_modernActionButton(
|
||||
icon: Icons.send_rounded,
|
||||
label: 'Request',
|
||||
label: 'Push Payment Request',
|
||||
color: const Color(0xFF10B981),
|
||||
isLoading: isActing,
|
||||
onPressed: _onRequest,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/groups/controllers/group_controller.dart';
|
||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../recipient/recipients_controller.dart';
|
||||
@@ -68,12 +69,9 @@ class _GroupCreateScreenState extends State<GroupCreateScreen> {
|
||||
|
||||
final recipients = _parseRecipients(_recipientsController.text);
|
||||
if (recipients.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text(
|
||||
AppSnackBar.show(
|
||||
context,
|
||||
'Please add at least one recipient in name,account format.',
|
||||
),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -94,13 +92,12 @@ class _GroupCreateScreenState extends State<GroupCreateScreen> {
|
||||
if (!mounted) return;
|
||||
|
||||
if (result.isSuccess) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Group created successfully.')),
|
||||
);
|
||||
AppSnackBar.showSuccess(context, 'Group created successfully.');
|
||||
context.pop(true); // Return true to indicate a new group was created
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(result.error ?? 'Failed to create group.')),
|
||||
AppSnackBar.showError(
|
||||
context,
|
||||
result.error ?? 'Failed to create group.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,9 @@ import 'package:qpay/screens/groups/models/recipient_group_model.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/groups/controllers/batch_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:skeletonizer/skeletonizer.dart';
|
||||
|
||||
class GroupDetailScreen extends StatefulWidget {
|
||||
final RecipientGroup group;
|
||||
@@ -16,11 +18,15 @@ class GroupDetailScreen extends StatefulWidget {
|
||||
State<GroupDetailScreen> createState() => _GroupDetailScreenState();
|
||||
}
|
||||
|
||||
class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
||||
class _GroupDetailScreenState extends State<GroupDetailScreen>
|
||||
with TickerProviderStateMixin {
|
||||
late GroupController groupController;
|
||||
late BatchController batchController;
|
||||
final _searchController = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
final _memberSearchController = TextEditingController();
|
||||
late final AnimationController _membersAnimController;
|
||||
String _memberQuery = '';
|
||||
String? _statusFilter;
|
||||
String? _currencyFilter;
|
||||
|
||||
@@ -50,6 +56,10 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
||||
_loadData();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_searchController.addListener(() => setState(() {}));
|
||||
_membersAnimController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 600),
|
||||
)..forward();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
@@ -77,55 +87,585 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
||||
}
|
||||
|
||||
void _showMembersSheet() {
|
||||
_memberSearchController.clear();
|
||||
_memberQuery = '';
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Theme.of(context).colorScheme.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
|
||||
),
|
||||
builder: (ctx) => DraggableScrollableSheet(
|
||||
initialChildSize: 0.6,
|
||||
minChildSize: 0.3,
|
||||
maxChildSize: 0.85,
|
||||
builder: (ctx) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.7,
|
||||
minChildSize: 0.4,
|
||||
maxChildSize: 0.92,
|
||||
expand: false,
|
||||
builder: (context, scrollController) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setSheetState) {
|
||||
return Column(
|
||||
children: [
|
||||
_buildMembersSheetHandle(),
|
||||
_buildMembersSheetHeader(
|
||||
setSheetState,
|
||||
scrollController,
|
||||
),
|
||||
Expanded(
|
||||
child: ListenableBuilder(
|
||||
listenable: groupController,
|
||||
builder: (context, _) {
|
||||
final allMembers = groupController.model.members;
|
||||
final filtered = _filteredMembers(allMembers);
|
||||
if (groupController.model.isLoading) {
|
||||
return _buildMembersSkeletonList(
|
||||
scrollController,
|
||||
);
|
||||
}
|
||||
if (allMembers.isEmpty) {
|
||||
return _buildMembersEmptyState();
|
||||
}
|
||||
if (filtered.isEmpty) {
|
||||
return _buildNoSearchResultsState();
|
||||
}
|
||||
return ListView.separated(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
itemCount: filtered.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(height: 10),
|
||||
itemBuilder: (context, index) {
|
||||
return _buildAnimatedMemberCard(
|
||||
index,
|
||||
filtered[index],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMembersSheetHandle() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.only(top: 10, bottom: 6),
|
||||
child: Container(
|
||||
width: 44,
|
||||
height: 5,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMembersSheetHeader(
|
||||
StateSetter setSheetState,
|
||||
ScrollController scrollController,
|
||||
) {
|
||||
final total = groupController.model.members.length;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 12, 8),
|
||||
child: Column(
|
||||
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: [
|
||||
const Text(
|
||||
'Members',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'$total ${total == 1 ? "member" : "members"} in this group',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
icon: Icon(Icons.close, color: Colors.grey.shade700),
|
||||
tooltip: 'Close',
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${groupController.model.members.length} member(s)',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
|
||||
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),
|
||||
const Divider(),
|
||||
Expanded(
|
||||
child: ListenableBuilder(
|
||||
listenable: groupController,
|
||||
builder: (context, _) {
|
||||
if (groupController.model.isLoading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
prefixIcon: Icon(
|
||||
Icons.search,
|
||||
color: Colors.grey.shade600,
|
||||
size: 20,
|
||||
),
|
||||
suffixIcon: _memberQuery.isNotEmpty
|
||||
? IconButton(
|
||||
icon: Icon(
|
||||
Icons.clear,
|
||||
color: Colors.grey.shade600,
|
||||
size: 18,
|
||||
),
|
||||
onPressed: () {
|
||||
_memberSearchController.clear();
|
||||
setSheetState(() => _memberQuery = '');
|
||||
},
|
||||
)
|
||||
: null,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 12,
|
||||
),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (groupController.model.members.isEmpty) {
|
||||
List<RecipientGroupMember> _filteredMembers(
|
||||
List<RecipientGroupMember> members,
|
||||
) {
|
||||
if (_memberQuery.trim().isEmpty) return members;
|
||||
final q = _memberQuery.toLowerCase().trim();
|
||||
return members.where((m) {
|
||||
final r = m.recipient;
|
||||
return (r.name?.toLowerCase().contains(q) ?? false) ||
|
||||
(r.account?.toLowerCase().contains(q) ?? false) ||
|
||||
(r.email?.toLowerCase().contains(q) ?? false) ||
|
||||
(r.phoneNumber?.toLowerCase().contains(q) ?? false) ||
|
||||
(r.latestProviderLabel?.toLowerCase().contains(q) ?? false);
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Widget _buildAnimatedMemberCard(int index, RecipientGroupMember member) {
|
||||
final clampedIndex = index.clamp(0, 5);
|
||||
final animation = Tween<Offset>(
|
||||
begin: const Offset(0, 0.06),
|
||||
end: Offset.zero,
|
||||
).animate(
|
||||
CurvedAnimation(
|
||||
parent: _membersAnimController,
|
||||
curve: Interval(
|
||||
0.05 * clampedIndex,
|
||||
1.0,
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
),
|
||||
);
|
||||
return FadeTransition(
|
||||
opacity: _membersAnimController,
|
||||
child: SlideTransition(
|
||||
position: animation,
|
||||
child: _buildMemberCard(member),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMemberCard(RecipientGroupMember member) {
|
||||
final recipient = member.recipient;
|
||||
final name = recipient.name?.isNotEmpty == true
|
||||
? recipient.name!
|
||||
: 'Unnamed recipient';
|
||||
final initials = _initialsFor(name);
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.grey.shade200, width: 1),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 14,
|
||||
horizontal: 14,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildMemberAvatar(initials),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.black87,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_buildMemberSubtitleRow(recipient, member.account),
|
||||
if (recipient.phoneNumber != null &&
|
||||
recipient.phoneNumber!.isNotEmpty) ...[
|
||||
const SizedBox(height: 6),
|
||||
_buildContactLine(
|
||||
Icons.phone_outlined,
|
||||
recipient.phoneNumber!,
|
||||
),
|
||||
],
|
||||
if (recipient.email != null &&
|
||||
recipient.email!.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
_buildContactLine(
|
||||
Icons.email_outlined,
|
||||
recipient.email!,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
_buildMemberPopupMenu(member),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMemberAvatar(String initials) {
|
||||
return Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Theme.of(context).colorScheme.primary,
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.7),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.25),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
initials,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMemberSubtitleRow(
|
||||
Recipient recipient,
|
||||
String? memberAccount,
|
||||
) {
|
||||
final account = memberAccount?.isNotEmpty == true
|
||||
? memberAccount
|
||||
: recipient.account;
|
||||
final hasAccount = account != null && account.isNotEmpty;
|
||||
final hasProvider = recipient.latestProviderLabel != null &&
|
||||
recipient.latestProviderLabel!.isNotEmpty;
|
||||
if (!hasAccount && !hasProvider) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 6,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
if (hasProvider)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
recipient.latestProviderLabel!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (hasAccount)
|
||||
Text(
|
||||
account!,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Colors.grey.shade700,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContactLine(IconData icon, String text) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 13, color: Colors.grey.shade500),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMemberPopupMenu(RecipientGroupMember member) {
|
||||
return PopupMenuButton<String>(
|
||||
icon: Icon(
|
||||
Icons.more_vert,
|
||||
color: Colors.grey.shade500,
|
||||
size: 20,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
splashRadius: 20,
|
||||
tooltip: 'More options',
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
onSelected: (value) async {
|
||||
if (value == 'remove') {
|
||||
await _confirmRemoveMember(member);
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'remove',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.person_remove_outlined,
|
||||
size: 18, color: Colors.red),
|
||||
SizedBox(width: 10),
|
||||
Text(
|
||||
'Remove from group',
|
||||
style: TextStyle(
|
||||
color: Colors.red,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _confirmRemoveMember(RecipientGroupMember member) async {
|
||||
final name = member.recipient.name ?? 'this member';
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
title: const Text('Remove member'),
|
||||
content: Text('Are you sure you want to remove $name from this group?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Remove'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed == true && mounted) {
|
||||
await groupController.removeMember(
|
||||
groupId: widget.group.id ?? '',
|
||||
recipientId: member.id ?? '',
|
||||
userId: widget.group.userId ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildMembersSkeletonList(ScrollController scrollController) {
|
||||
return ListView.separated(
|
||||
controller: scrollController,
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||
itemCount: 6,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, index) {
|
||||
return Skeletonizer(
|
||||
enabled: true,
|
||||
child: _buildMemberCard(
|
||||
RecipientGroupMember(
|
||||
recipientGroupId: '',
|
||||
recipient: Recipient(
|
||||
name: 'Loading Name Here',
|
||||
account: '0000000000',
|
||||
phoneNumber: '+000 000 0000',
|
||||
email: 'loading@example.com',
|
||||
latestProviderLabel: 'PROVIDER',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMembersEmptyState() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 88,
|
||||
height: 88,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary
|
||||
.withValues(alpha: 0.08),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.people_alt_outlined,
|
||||
size: 44,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'No members yet',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Members added to this group will appear\nhere once they are available.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey.shade600,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNoSearchResultsState() {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
@@ -133,16 +673,26 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.person_outline,
|
||||
size: 48,
|
||||
Icons.search_off_outlined,
|
||||
size: 56,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'No members found.',
|
||||
'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,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -151,64 +701,15 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
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 {
|
||||
@@ -352,6 +853,8 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_scrollController.dispose();
|
||||
_memberSearchController.dispose();
|
||||
_membersAnimController.dispose();
|
||||
groupController.dispose();
|
||||
batchController.dispose();
|
||||
super.dispose();
|
||||
@@ -468,6 +971,13 @@ class _GroupDetailScreenState extends State<GroupDetailScreen> {
|
||||
onPressed: () => batchController.toggleSelectMode(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildIconButton(
|
||||
icon: Icons.refresh,
|
||||
tooltip: 'Refresh batches',
|
||||
isActive: false,
|
||||
onPressed: _loadData,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildIconButton(
|
||||
icon: Icons.add,
|
||||
tooltip: 'Create batch',
|
||||
|
||||
@@ -179,6 +179,13 @@ class _GroupsScreenState extends State<GroupsScreen> {
|
||||
onPressed: () => controller.toggleSelectMode(),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildIconButton(
|
||||
icon: Icons.refresh,
|
||||
tooltip: 'Refresh groups',
|
||||
isActive: false,
|
||||
onPressed: _loadData,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildIconButton(
|
||||
icon: Icons.add,
|
||||
tooltip: 'Create new group',
|
||||
|
||||
@@ -8,54 +8,37 @@ class HistoryModel {
|
||||
|
||||
List<Map<String, dynamic>> transactions = [];
|
||||
bool isLoading = false;
|
||||
bool isLoadingMore = false;
|
||||
bool isActing = false;
|
||||
String? errorMessage;
|
||||
|
||||
int currentPage = 0;
|
||||
int totalPages = 0;
|
||||
|
||||
String searchQuery = '';
|
||||
String? statusFilter;
|
||||
String? typeFilter;
|
||||
|
||||
bool selectMode = false;
|
||||
Set<String> selectedTransactionIds = {};
|
||||
}
|
||||
|
||||
class HistoryController extends ChangeNotifier {
|
||||
final HistoryModel model = HistoryModel();
|
||||
final Http http = Http();
|
||||
BuildContext context;
|
||||
bool _disposed = false;
|
||||
|
||||
HistoryController(this.context);
|
||||
|
||||
Future<ApiResponse<List<Map<String, dynamic>>>> getTransactions(Map<String, String> params) async {
|
||||
model.isLoading = true;
|
||||
if (params['page'] == '0') {
|
||||
model.transactions = getFakeTransactions();
|
||||
}
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
Map<String, dynamic> response = await http.get(
|
||||
'/public/transaction?${buildQueryParameters(params)}',
|
||||
);
|
||||
|
||||
if (params['page'] == '0') {
|
||||
model.transactions = [];
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
model.pageableModel = PageableModel.fromJson(response);
|
||||
|
||||
// pagination adds to current list instead of replacing it
|
||||
model.transactions =
|
||||
model.transactions +
|
||||
model.pageableModel!.content
|
||||
.map((e) => e as Map<String, dynamic>)
|
||||
.toList();
|
||||
|
||||
model.isLoading = false;
|
||||
notifyListeners();
|
||||
return ApiResponse.success(List<Map<String, dynamic>>.from(model.transactions));
|
||||
} catch (e) {
|
||||
logger.e(e);
|
||||
model.errorMessage = e.toString();
|
||||
model.transactions = [];
|
||||
model.isLoading = false;
|
||||
notifyListeners();
|
||||
return ApiResponse.failure(
|
||||
"Problem fetching transactions, are you connected to the internet?",
|
||||
);
|
||||
}
|
||||
void _safeNotify() {
|
||||
if (!_disposed) notifyListeners();
|
||||
}
|
||||
|
||||
String buildQueryParameters(Map<String, String> params) {
|
||||
@@ -70,6 +53,152 @@ class HistoryController extends ChangeNotifier {
|
||||
return query.substring(0, query.length - 1);
|
||||
}
|
||||
|
||||
Future<ApiResponse<List<Map<String, dynamic>>>> getTransactions(
|
||||
Map<String, String> params,
|
||||
) async {
|
||||
final isFirstPage = params['page'] == '0' || params['page'] == null;
|
||||
if (isFirstPage) {
|
||||
model.isLoading = true;
|
||||
model.transactions = [];
|
||||
model.currentPage = 0;
|
||||
} else {
|
||||
model.isLoadingMore = true;
|
||||
}
|
||||
_safeNotify();
|
||||
|
||||
params['partyType'] = 'INDIVIDUAL';
|
||||
|
||||
try {
|
||||
Map<String, dynamic> response = await http.get(
|
||||
'/public/transaction?${buildQueryParameters(params)}',
|
||||
);
|
||||
|
||||
model.pageableModel = PageableModel.fromJson(response);
|
||||
model.currentPage = model.pageableModel!.number;
|
||||
model.totalPages = model.pageableModel!.totalPages;
|
||||
|
||||
final newItems = model.pageableModel!.content
|
||||
.map((e) => Map<String, dynamic>.from(e as Map))
|
||||
.toList();
|
||||
|
||||
if (isFirstPage) {
|
||||
model.transactions = newItems;
|
||||
} else {
|
||||
model.transactions.addAll(newItems);
|
||||
}
|
||||
|
||||
model.isLoading = false;
|
||||
model.isLoadingMore = false;
|
||||
_safeNotify();
|
||||
return ApiResponse.success(model.transactions);
|
||||
} catch (e) {
|
||||
logger.e(e);
|
||||
model.errorMessage = e.toString();
|
||||
if (isFirstPage) {
|
||||
model.transactions = [];
|
||||
}
|
||||
model.isLoading = false;
|
||||
model.isLoadingMore = false;
|
||||
_safeNotify();
|
||||
return ApiResponse.failure(
|
||||
"Problem fetching transactions, are you connected to the internet?",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> searchTransactions(
|
||||
String userId, {
|
||||
String? search,
|
||||
String? status,
|
||||
String? type,
|
||||
}) async {
|
||||
model.searchQuery = search ?? '';
|
||||
model.statusFilter = status;
|
||||
model.typeFilter = type;
|
||||
final params = <String, String>{
|
||||
'userId': userId,
|
||||
'page': '0',
|
||||
'size': '20',
|
||||
'sort': 'createdAt,desc',
|
||||
};
|
||||
if (search != null && search.isNotEmpty) {
|
||||
params['creditAccount'] = search;
|
||||
params['creditEmail'] = search;
|
||||
params['creditPhone'] = search;
|
||||
params['creditName'] = search;
|
||||
params['billName'] = search;
|
||||
params['amount'] = search;
|
||||
}
|
||||
if (status != null && status.isNotEmpty) {
|
||||
params['status'] = status;
|
||||
}
|
||||
if (type != null && type.isNotEmpty) {
|
||||
params['type'] = type;
|
||||
}
|
||||
await getTransactions(params);
|
||||
}
|
||||
|
||||
Future<void> loadNextPage(String userId) async {
|
||||
if (model.isLoadingMore || model.currentPage + 1 >= model.totalPages) {
|
||||
return;
|
||||
}
|
||||
final params = <String, String>{
|
||||
'userId': userId,
|
||||
'page': (model.currentPage + 1).toString(),
|
||||
'size': '20',
|
||||
'sort': 'createdAt,desc',
|
||||
};
|
||||
if (model.searchQuery.isNotEmpty) {
|
||||
params['creditAccount'] = model.searchQuery;
|
||||
params['creditEmail'] = model.searchQuery;
|
||||
params['creditPhone'] = model.searchQuery;
|
||||
params['creditName'] = model.searchQuery;
|
||||
params['billName'] = model.searchQuery;
|
||||
params['amount'] = model.searchQuery;
|
||||
}
|
||||
if (model.statusFilter != null && model.statusFilter!.isNotEmpty) {
|
||||
params['status'] = model.statusFilter!;
|
||||
}
|
||||
if (model.typeFilter != null && model.typeFilter!.isNotEmpty) {
|
||||
params['type'] = model.typeFilter!;
|
||||
}
|
||||
await getTransactions(params);
|
||||
}
|
||||
|
||||
void toggleSelectMode() {
|
||||
model.selectMode = !model.selectMode;
|
||||
if (!model.selectMode) {
|
||||
model.selectedTransactionIds.clear();
|
||||
}
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
void toggleTransactionSelection(String id) {
|
||||
if (id.isEmpty) return;
|
||||
if (model.selectedTransactionIds.contains(id)) {
|
||||
model.selectedTransactionIds.remove(id);
|
||||
} else {
|
||||
model.selectedTransactionIds.add(id);
|
||||
}
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
/// Removes selected transactions from the local list. The history
|
||||
/// endpoint does not support deletes, so this is a client-side action.
|
||||
void deleteSelectedTransactions() {
|
||||
final ids = List<String>.from(model.selectedTransactionIds);
|
||||
model.transactions.removeWhere((t) => ids.contains(t['id'] as String?));
|
||||
model.selectMode = false;
|
||||
model.selectedTransactionIds.clear();
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
void clearFilters() {
|
||||
model.statusFilter = null;
|
||||
model.typeFilter = null;
|
||||
_safeNotify();
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> getFakeTransactions() {
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'package:qpay/screens/receipt/receipt_controller.dart';
|
||||
import 'package:qpay/screens/transaction_controller.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:qpay/screens/history/history_controller.dart';
|
||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||
import 'package:qpay/widgets/transaction_item_widget.dart';
|
||||
import 'package:qpay/screens/transactions/transaction_model.dart' as txn;
|
||||
|
||||
@@ -20,39 +21,61 @@ class _HistoryScreenState extends State<HistoryScreen>
|
||||
with TickerProviderStateMixin {
|
||||
late HistoryController controller;
|
||||
late SharedPreferences prefs;
|
||||
late AnimationController _controller;
|
||||
late TransactionController transactionController;
|
||||
|
||||
final TextEditingController _searchController = TextEditingController();
|
||||
final Map<String, String> _queryParams = {};
|
||||
final int _pageSize = 8;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
late final AnimationController _entryController;
|
||||
late final Animation<double> _entryFade;
|
||||
late final Animation<Offset> _entrySlide;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
controller = HistoryController(context);
|
||||
setupData();
|
||||
|
||||
transactionController = Provider.of<TransactionController>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(milliseconds: 3000),
|
||||
// Short, subtle entry animation (replaces the old 3-second stagger)
|
||||
_entryController = AnimationController(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
vsync: this,
|
||||
)..forward();
|
||||
);
|
||||
_entryFade = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(parent: _entryController, curve: Curves.easeOut));
|
||||
_entrySlide = Tween<Offset>(
|
||||
begin: const Offset(0, 0.02),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(parent: _entryController, curve: Curves.easeOut));
|
||||
_entryController.forward();
|
||||
|
||||
// Listen to controller changes to rebuild when transactions are loaded
|
||||
controller.addListener(() => setState(() {}));
|
||||
_searchController.addListener(() => setState(() {}));
|
||||
_scrollController.addListener(_onScroll);
|
||||
|
||||
setupData();
|
||||
}
|
||||
|
||||
void setupData() async {
|
||||
Future<void> setupData() async {
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
final userId = prefs.getString("userId") ?? '';
|
||||
await controller.searchTransactions(userId);
|
||||
}
|
||||
|
||||
await controller.getTransactions({
|
||||
'userId': prefs.getString("userId")!,
|
||||
'page': '0',
|
||||
'size': _pageSize.toString(),
|
||||
'sort': 'createdAt,desc',
|
||||
});
|
||||
void _onScroll() {
|
||||
if (_scrollController.position.pixels >=
|
||||
_scrollController.position.maxScrollExtent - 200) {
|
||||
final userId = prefs.getString("userId") ?? '';
|
||||
if (userId.isNotEmpty) {
|
||||
controller.loadNextPage(userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the repeat action for a transaction.
|
||||
@@ -60,10 +83,178 @@ class _HistoryScreenState extends State<HistoryScreen>
|
||||
final receiptController = ReceiptController(context);
|
||||
final transaction = txn.TransactionModel.fromJson(item);
|
||||
await receiptController.repeatTransaction(transaction);
|
||||
if (mounted) {
|
||||
context.push('/make-payment');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onDeleteSelected() async {
|
||||
final count = controller.model.selectedTransactionIds.length;
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('Delete transactions'),
|
||||
content: Text('Remove $count transaction(s) from this list?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
controller.deleteSelectedTransactions();
|
||||
if (mounted) {
|
||||
AppSnackBar.show(
|
||||
context,
|
||||
'Transactions removed from this list',
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showFilterSheet() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) {
|
||||
return StatefulBuilder(
|
||||
builder: (context, setSheetState) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'Filter Transactions',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
setSheetState(() {
|
||||
controller.model.statusFilter = null;
|
||||
controller.model.typeFilter = null;
|
||||
});
|
||||
},
|
||||
child: const Text('Clear All'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String?>(
|
||||
initialValue: controller.model.statusFilter,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Status',
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: null, child: Text('All')),
|
||||
DropdownMenuItem(
|
||||
value: 'SUCCESS',
|
||||
child: Text('Success'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'PENDING',
|
||||
child: Text('Pending'),
|
||||
),
|
||||
DropdownMenuItem(value: 'FAILED', child: Text('Failed')),
|
||||
],
|
||||
onChanged: (v) {
|
||||
setSheetState(() => controller.model.statusFilter = v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String?>(
|
||||
initialValue: controller.model.typeFilter,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Type',
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(value: null, child: Text('All')),
|
||||
DropdownMenuItem(
|
||||
value: 'REQUEST',
|
||||
child: Text('Request'),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'PAYMENT',
|
||||
child: Text('Payment'),
|
||||
),
|
||||
DropdownMenuItem(value: 'REFUND', child: Text('Refund')),
|
||||
],
|
||||
onChanged: (v) {
|
||||
setSheetState(() => controller.model.typeFilter = v);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(ctx).pop();
|
||||
final userId = prefs.getString("userId") ?? '';
|
||||
controller.searchTransactions(
|
||||
userId,
|
||||
search: _searchController.text.isNotEmpty
|
||||
? _searchController.text
|
||||
: null,
|
||||
status: controller.model.statusFilter,
|
||||
type: controller.model.typeFilter,
|
||||
);
|
||||
},
|
||||
child: const Text('Apply Filters'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchController.dispose();
|
||||
_scrollController.dispose();
|
||||
_entryController.dispose();
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -71,200 +262,419 @@ class _HistoryScreenState extends State<HistoryScreen>
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: CustomScrollView(
|
||||
appBar: AppBar(title: const Text('History'), centerTitle: true),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
title: Text(
|
||||
'History',
|
||||
style: TextStyle(fontSize: 20, color: Colors.black),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: FadeTransition(
|
||||
opacity: _entryFade,
|
||||
child: SlideTransition(
|
||||
position: _entrySlide,
|
||||
child: Center(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SizedBox(
|
||||
width: double.parse(
|
||||
final width =
|
||||
constraints.maxWidth > ResponsivePolicy.md
|
||||
? ResponsivePolicy.md.toString()
|
||||
: constraints.maxWidth.toString(),
|
||||
),
|
||||
? ResponsivePolicy.md.toDouble()
|
||||
: constraints.maxWidth;
|
||||
return SizedBox(
|
||||
width: width,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildSearchField(controller),
|
||||
SizedBox(height: 10),
|
||||
if (controller.model.transactions.isEmpty)
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(height: 30),
|
||||
Text(
|
||||
'No transactions yet. Make a payment to see your recent activity.',
|
||||
style: TextStyle(fontSize: 16),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
_buildSearchRow(),
|
||||
const SizedBox(height: 12),
|
||||
_buildContent(),
|
||||
],
|
||||
),
|
||||
if (controller.model.transactions.isNotEmpty)
|
||||
Column(
|
||||
children: [
|
||||
ListView(
|
||||
shrinkWrap: true,
|
||||
physics:
|
||||
const NeverScrollableScrollPhysics(),
|
||||
children: [
|
||||
...controller.model.transactions.map(
|
||||
(e) =>
|
||||
_buildTransactionItem(e, context),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
if (controller.model.pageableModel !=
|
||||
null &&
|
||||
controller.model.pageableModel!.number <
|
||||
controller
|
||||
.model
|
||||
.pageableModel!
|
||||
.totalPages)
|
||||
OutlinedButton(
|
||||
child: Text('Load more'),
|
||||
onPressed: () {
|
||||
controller.getTransactions({
|
||||
'userId': prefs.getString(
|
||||
"userId",
|
||||
)!,
|
||||
'page':
|
||||
(controller
|
||||
.model
|
||||
.pageableModel!
|
||||
.number +
|
||||
1)
|
||||
.toString(),
|
||||
'size': _pageSize.toString(),
|
||||
'sort': 'createdAt,desc',
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (controller.model.selectMode) _buildSelectionBottomBar(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSearchField(HistoryController controller) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
Widget _buildSearchRow() {
|
||||
final hasFilters =
|
||||
controller.model.statusFilter != null ||
|
||||
controller.model.typeFilter != null;
|
||||
return 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);
|
||||
});
|
||||
},
|
||||
controller: _searchController,
|
||||
keyboardType: TextInputType.text,
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Search by name, account, email, phone',
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
suffixIcon: _searchController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchController.clear();
|
||||
_queryParams.clear();
|
||||
controller.getTransactions({
|
||||
'userId': prefs.getString("userId")!,
|
||||
});
|
||||
final userId = prefs.getString("userId") ?? '';
|
||||
controller.searchTransactions(
|
||||
userId,
|
||||
status: controller.model.statusFilter,
|
||||
type: controller.model.typeFilter,
|
||||
);
|
||||
},
|
||||
icon: Icon(Icons.clear),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
)
|
||||
: null,
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
onChanged: (value) {
|
||||
// controller.updateCreditAccount(value);
|
||||
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,
|
||||
);
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter an amount';
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildIconButton(
|
||||
icon: Icons.filter_list,
|
||||
tooltip: 'Filter transactions',
|
||||
isActive: hasFilters,
|
||||
onPressed: _showFilterSheet,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildIconButton(
|
||||
icon: controller.model.selectMode
|
||||
? Icons.checklist
|
||||
: Icons.checklist_outlined,
|
||||
tooltip: 'Select transactions',
|
||||
isActive: controller.model.selectMode,
|
||||
onPressed: controller.toggleSelectMode,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildIconButton(
|
||||
icon: Icons.refresh,
|
||||
tooltip: 'Refresh transactions',
|
||||
isActive: false,
|
||||
onPressed: () async {
|
||||
await setupData();
|
||||
if (mounted) {
|
||||
AppSnackBar.show(
|
||||
context,
|
||||
'Transactions refreshed',
|
||||
duration: const Duration(seconds: 1),
|
||||
);
|
||||
}
|
||||
if (double.tryParse(value) == null) {
|
||||
return 'Please enter a valid amount';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_buildIconButton(
|
||||
icon: Icons.add,
|
||||
tooltip: 'New payment',
|
||||
isActive: false,
|
||||
onPressed: () => context.push('/recipients'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIconButton({
|
||||
required IconData icon,
|
||||
required String tooltip,
|
||||
required bool isActive,
|
||||
required VoidCallback onPressed,
|
||||
}) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isActive
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(icon, color: isActive ? Colors.white : Colors.grey.shade700),
|
||||
tooltip: tooltip,
|
||||
onPressed: onPressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent() {
|
||||
if (controller.model.isLoading) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 60),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (controller.model.transactions.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.receipt_long_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'No transactions yet.',
|
||||
style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tap + to make a new payment.',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final hasFilters =
|
||||
controller.model.statusFilter != null ||
|
||||
controller.model.typeFilter != null;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (controller.model.selectMode)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${controller.model.selectedTransactionIds.length} selected',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (hasFilters)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
if (controller.model.statusFilter != null)
|
||||
_activeFilterChip(
|
||||
'Status: ${controller.model.statusFilter}',
|
||||
() {
|
||||
controller.model.statusFilter = null;
|
||||
final userId = prefs.getString("userId") ?? '';
|
||||
controller.searchTransactions(
|
||||
userId,
|
||||
search: _searchController.text.isNotEmpty
|
||||
? _searchController.text
|
||||
: null,
|
||||
type: controller.model.typeFilter,
|
||||
);
|
||||
},
|
||||
),
|
||||
if (controller.model.typeFilter != null)
|
||||
_activeFilterChip('Type: ${controller.model.typeFilter}', () {
|
||||
controller.model.typeFilter = null;
|
||||
final userId = prefs.getString("userId") ?? '';
|
||||
controller.searchTransactions(
|
||||
userId,
|
||||
search: _searchController.text.isNotEmpty
|
||||
? _searchController.text
|
||||
: null,
|
||||
status: controller.model.statusFilter,
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
...controller.model.transactions.map(
|
||||
(e) => _buildTransactionItem(e, context),
|
||||
),
|
||||
if (controller.model.isLoadingMore)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _activeFilterChip(String label, VoidCallback onRemove) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withAlpha(20),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withAlpha(60),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
InkWell(
|
||||
onTap: onRemove,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(2),
|
||||
child: Icon(
|
||||
Icons.close,
|
||||
size: 14,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTransactionItem(Map<String, dynamic> e, BuildContext context) {
|
||||
int index = controller.model.transactions.indexOf(e);
|
||||
Animation<Offset> animation =
|
||||
Tween<Offset>(begin: Offset(0, -0.25), end: Offset.zero).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: Interval(0.175 * index, 1.0, curve: Curves.easeIn),
|
||||
),
|
||||
);
|
||||
final id = e['id'] as String? ?? '';
|
||||
final isSelected = controller.model.selectedTransactionIds.contains(id);
|
||||
final inSelectMode = controller.model.selectMode;
|
||||
|
||||
return SlideTransition(
|
||||
position: animation,
|
||||
child: TransactionItemWidget(
|
||||
return Stack(
|
||||
children: [
|
||||
TransactionItemWidget(
|
||||
transaction: e,
|
||||
enabled: controller.model.isLoading,
|
||||
onRepeat: () async {
|
||||
onRepeat: inSelectMode
|
||||
? null
|
||||
: () async {
|
||||
await _repeatTransaction(e);
|
||||
if (context.mounted) {
|
||||
context.push('/make-payment');
|
||||
}
|
||||
},
|
||||
),
|
||||
if (inSelectMode)
|
||||
Positioned.fill(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: () => controller.toggleTransactionSelection(id),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.08)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Icon(
|
||||
isSelected
|
||||
? Icons.check_box
|
||||
: Icons.check_box_outline_blank,
|
||||
size: 22,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Colors.grey.shade500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectionBottomBar() {
|
||||
final selectedCount = controller.model.selectedTransactionIds.length;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.08),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, -2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'$selectedCount selected',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
TextButton.icon(
|
||||
onPressed: () => controller.toggleSelectMode(),
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
label: const Text('Cancel'),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
TextButton.icon(
|
||||
onPressed: selectedCount > 0 ? _onDeleteSelected : null,
|
||||
icon: const Icon(Icons.delete_outline, size: 20),
|
||||
label: const Text('Delete'),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -155,7 +155,7 @@ class HomeController extends ChangeNotifier {
|
||||
|
||||
try {
|
||||
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);
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qpay/auth_state.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/home/home_controller.dart';
|
||||
import 'package:qpay/screens/receipt/receipt_controller.dart';
|
||||
import 'package:qpay/screens/transaction_controller.dart';
|
||||
import 'package:qpay/screens/transactions/transaction_model.dart' as txn;
|
||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||
import 'package:qpay/widgets/transaction_item_widget.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -194,6 +196,8 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isMobile = constraints.maxWidth < ResponsivePolicy.md;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: isDark
|
||||
? const Color(0xFF0D0D0D)
|
||||
@@ -206,6 +210,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
child: CustomScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
slivers: [
|
||||
if (isMobile)
|
||||
SliverAppBar(
|
||||
pinned: false,
|
||||
floating: true,
|
||||
@@ -214,60 +219,11 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
backgroundColor: isDark
|
||||
? const Color(0xFF0D0D0D)
|
||||
: const Color(0xFFF8F9FA),
|
||||
title: constraints.maxWidth < ResponsivePolicy.md
|
||||
? Image(
|
||||
title: Image(
|
||||
image: const AssetImage('assets/velocity.png'),
|
||||
width: 130,
|
||||
)
|
||||
: null,
|
||||
actions: [
|
||||
if (_isLoggedIn) ...[
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
margin: const EdgeInsets.only(right: 4),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.12)
|
||||
: Colors.black87.withAlpha(20),
|
||||
),
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
theme.colorScheme.primary.withValues(
|
||||
alpha: 0.15,
|
||||
),
|
||||
theme.colorScheme.tertiary.withValues(
|
||||
alpha: 0.05,
|
||||
),
|
||||
],
|
||||
stops: const [0.3, 0.7],
|
||||
begin: Alignment.topRight,
|
||||
end: Alignment.bottomLeft,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
initials,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
Icons.logout_rounded,
|
||||
color: isDark ? Colors.white54 : Colors.black54,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: _showLogoutDialog,
|
||||
),
|
||||
],
|
||||
],
|
||||
actions: _buildAppBarActions(theme, isDark),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Center(
|
||||
@@ -282,6 +238,10 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isMobile) ...[
|
||||
_buildWebHeader(theme, isDark),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
FadeTransition(
|
||||
opacity: _providersFadeAnimation,
|
||||
child: SlideTransition(
|
||||
@@ -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
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
@@ -384,21 +409,17 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
_isZWGSelected = !_isZWGSelected;
|
||||
});
|
||||
if (_isZWGSelected) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Center(
|
||||
child: const Text(
|
||||
AppSnackBar.show(
|
||||
context,
|
||||
'ZWG currency not supported yet',
|
||||
customContent: const Center(
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -749,28 +770,12 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Register or Login for more',
|
||||
'Register or Login for more features',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
InkWell(
|
||||
onTap: () => _showFeaturesPopup(context),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Text(
|
||||
'features',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: theme.colorScheme.primary,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -825,6 +830,7 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
setState(() {
|
||||
_isLoggedIn = false;
|
||||
});
|
||||
authState.refresh();
|
||||
setupDataAndTransitions();
|
||||
},
|
||||
),
|
||||
@@ -833,201 +839,4 @@ class _HomeScreenState extends State<HomeScreen> with TickerProviderStateMixin {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
// Features Popup
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
void _showFeaturesPopup(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return Dialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.rectangle,
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.1),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Image(
|
||||
image: AssetImage("assets/favicon.png"),
|
||||
width: 45,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Unlock Additional Features',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Register or login to access these great features',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildFeatureItem(
|
||||
Icons.account_balance_wallet,
|
||||
'Dual Currency Wallets',
|
||||
'USD and ZWG wallets for prepayments that can be utilized later',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildFeatureItem(
|
||||
Icons.history,
|
||||
'Transaction History',
|
||||
'All transactions are saved for reference from any device',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildFeatureItem(
|
||||
Icons.people,
|
||||
'Recipient Management',
|
||||
'Save and manage recipient details for future use',
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildFeatureItem(
|
||||
Icons.schedule,
|
||||
'Smart Payments',
|
||||
'Schedule payments and split bills with friends',
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Maybe Later',
|
||||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.6),
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
context.push('/onboarding/landing');
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: const Text(
|
||||
'Get Started',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFeatureItem(IconData icon, String title, String description) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 20,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
description,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package: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 {
|
||||
const CompleteScreen({super.key});
|
||||
@@ -12,67 +12,79 @@ class CompleteScreen extends StatefulWidget {
|
||||
class _CompleteScreenState extends State<CompleteScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(25),
|
||||
child: Center(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
|
||||
? ResponsivePolicy.md.toDouble()
|
||||
: constraints.maxWidth;
|
||||
return SizedBox(
|
||||
width: maxWidth,
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return OnboardingLayout(
|
||||
// No back button - the user shouldn't be able to go back to the
|
||||
// verification step after their account is fully set up.
|
||||
showBackButton: false,
|
||||
child: OnboardingCard(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(height: 75),
|
||||
Text(
|
||||
'Registration Complete',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
// Success badge - green to differentiate from the brand primary.
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.green.withValues(alpha: 0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Colors.green,
|
||||
size: 56,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Thank you for registering with Velocity payments',
|
||||
style: TextStyle(fontSize: 18),
|
||||
"You're all set!",
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: colorScheme.onSurface,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
Image.asset('assets/landing.png', width: 300),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Tap Login to get started',
|
||||
style: TextStyle(fontSize: 18),
|
||||
'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,
|
||||
),
|
||||
SizedBox(height: 30),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 25),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
),
|
||||
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');
|
||||
},
|
||||
child: Text('Go to Login', style: TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/onboarding/login/login_controller.dart';
|
||||
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
|
||||
|
||||
class LandingScreen extends StatefulWidget {
|
||||
const LandingScreen({super.key});
|
||||
@@ -11,82 +12,113 @@ class LandingScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LandingScreenState extends State<LandingScreen> {
|
||||
late OnboardingController onboardingController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
onboardingController = Provider.of<OnboardingController>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
}
|
||||
|
||||
void _goToRegister() {
|
||||
// Reset the registration state so a fresh sign-up starts cleanly.
|
||||
onboardingController.resetState();
|
||||
context.go('/onboarding/phone');
|
||||
}
|
||||
|
||||
// Max content width on large screens (desktop / web).
|
||||
static const double _maxContentWidth = 1200;
|
||||
// Max content width on tablet — the current mobile cap lifted slightly
|
||||
// so the page breathes a bit on landscape phones / small tablets.
|
||||
static const double _maxTabletWidth = 760;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
final isTablet = width >= ResponsivePolicy.md;
|
||||
final isDesktop = width >= ResponsivePolicy.lg;
|
||||
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(25),
|
||||
child: Center(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
|
||||
? ResponsivePolicy.md.toDouble()
|
||||
: constraints.maxWidth;
|
||||
return SizedBox(
|
||||
width: maxWidth,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
// Use a Stack so the gradient background is guaranteed to cover the
|
||||
// full viewport (including the area under the SafeArea) regardless
|
||||
// of how tall the content ends up being.
|
||||
body: Stack(
|
||||
children: [
|
||||
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)),
|
||||
),
|
||||
),
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
colorScheme.primary.withValues(alpha: 0.08),
|
||||
colorScheme.surface,
|
||||
colorScheme.secondary.withValues(alpha: 0.05),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
),
|
||||
),
|
||||
SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: _maxContentWidth),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isDesktop ? 48 : 20,
|
||||
vertical: isDesktop ? 32 : 16,
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final contentMaxWidth = isDesktop
|
||||
? _maxContentWidth
|
||||
: (isTablet
|
||||
? _maxTabletWidth
|
||||
: constraints.maxWidth);
|
||||
return SizedBox(
|
||||
width: contentMaxWidth,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
_buildTopBar(theme, colorScheme),
|
||||
SizedBox(height: isDesktop ? 40 : 24),
|
||||
_buildHero(theme, colorScheme, isDesktop),
|
||||
SizedBox(height: isDesktop ? 72 : 36),
|
||||
_buildValuePropsHeadline(theme, colorScheme),
|
||||
const SizedBox(height: 28),
|
||||
_buildValueProps(isTablet),
|
||||
SizedBox(height: isDesktop ? 64 : 32),
|
||||
_buildCallToActions(
|
||||
theme,
|
||||
colorScheme,
|
||||
isDesktop,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/');
|
||||
},
|
||||
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
|
||||
child: Text(
|
||||
'Continue as guest?',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: colorScheme.onSurface.withValues(
|
||||
alpha: 0.7,
|
||||
),
|
||||
],
|
||||
)
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildFooter(theme, colorScheme),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -95,6 +127,530 @@ class _LandingScreenState extends State<LandingScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopBar(ThemeData theme, ColorScheme colorScheme) {
|
||||
return Row(
|
||||
children: [
|
||||
Image(image: AssetImage('assets/velocity.png'), width: 150),
|
||||
const Spacer(),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/onboarding/login');
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colorScheme.primary,
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
child: const Text('Sign in'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHero(ThemeData theme, ColorScheme colorScheme, bool isDesktop) {
|
||||
final textBlock = Column(
|
||||
crossAxisAlignment: isDesktop
|
||||
? CrossAxisAlignment.start
|
||||
: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isDesktop
|
||||
? Colors.white.withValues(alpha: 0.18)
|
||||
: colorScheme.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: isDesktop
|
||||
? Border.all(color: Colors.white.withValues(alpha: 0.25))
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.flash_on_rounded,
|
||||
size: 14,
|
||||
color: isDesktop ? Colors.white : colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'Fast. Secure. Local.',
|
||||
style: theme.textTheme.labelMedium?.copyWith(
|
||||
color: isDesktop ? Colors.white : colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.4,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: isDesktop ? 20 : 16),
|
||||
Text(
|
||||
'Welcome to Velocity',
|
||||
textAlign: isDesktop ? TextAlign.start : TextAlign.center,
|
||||
style:
|
||||
(isDesktop
|
||||
? theme.textTheme.displaySmall
|
||||
: theme.textTheme.headlineMedium)
|
||||
?.copyWith(
|
||||
color: isDesktop ? null : colorScheme.primary,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.1,
|
||||
),
|
||||
),
|
||||
SizedBox(height: isDesktop ? 16 : 8),
|
||||
Text(
|
||||
'The fastest way to pay for local goods and services.',
|
||||
textAlign: isDesktop ? TextAlign.start : TextAlign.center,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
color: isDesktop
|
||||
? colorScheme.onSurface.withValues(alpha: 0.75)
|
||||
: colorScheme.onSurface.withValues(alpha: 0.8),
|
||||
height: 1.4,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
if (isDesktop) ...[
|
||||
const SizedBox(height: 28),
|
||||
Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 12,
|
||||
children: [
|
||||
_buildHeroCta(context, colorScheme),
|
||||
_buildHeroSecondaryCta(context, colorScheme),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
final imageBlock = ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.primary.withValues(alpha: 0.25),
|
||||
blurRadius: 32,
|
||||
offset: const Offset(0, 18),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Image.asset(
|
||||
'assets/signup.png',
|
||||
width: isDesktop ? 360 : 260,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (isDesktop) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(40),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
colorScheme.primary.withValues(alpha: 0.06),
|
||||
colorScheme.secondary.withValues(alpha: 0.04),
|
||||
],
|
||||
),
|
||||
border: Border.all(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.06),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(flex: 5, child: textBlock),
|
||||
const SizedBox(width: 32),
|
||||
Expanded(flex: 4, child: Center(child: imageBlock)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile / tablet — original vertical hero with gradient background.
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32, horizontal: 20),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [colorScheme.primary, colorScheme.secondary],
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.primary.withValues(alpha: 0.25),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.bolt_rounded,
|
||||
color: Colors.white,
|
||||
size: 44,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Welcome to Velocity',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.headlineMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'The fastest way to pay for local goods\nand services',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.white.withValues(alpha: 0.92),
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
imageBlock,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroCta(BuildContext context, ColorScheme colorScheme) {
|
||||
return SizedBox(
|
||||
height: 52,
|
||||
child: ElevatedButton(
|
||||
onPressed: _goToRegister,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('Create Free Account'),
|
||||
SizedBox(width: 8),
|
||||
Icon(Icons.arrow_forward_rounded, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeroSecondaryCta(BuildContext context, ColorScheme colorScheme) {
|
||||
return SizedBox(
|
||||
height: 52,
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
context.go('/onboarding/login');
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: colorScheme.primary, width: 1.5),
|
||||
foregroundColor: colorScheme.primary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 28),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
child: const Text('Sign in'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildValuePropsHeadline(ThemeData theme, ColorScheme colorScheme) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'Why register?',
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'Unlock the full Velocity experience',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
color: colorScheme.onSurface,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 600),
|
||||
child: Text(
|
||||
'Create a free account to access powerful features designed to make your payments effortless.',
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.65),
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildValueProps(bool isWide) {
|
||||
final props = const [
|
||||
_ValueProp(
|
||||
icon: Icons.groups_2_rounded,
|
||||
title: 'Batch Pay Groups',
|
||||
description:
|
||||
'Send payments to many recipients at once. Perfect for splitting bills, paying teams, or settling group purchases in a single tap.',
|
||||
accent: Color(0xFF9D6711),
|
||||
),
|
||||
_ValueProp(
|
||||
icon: Icons.devices_rounded,
|
||||
title: 'Access Anywhere',
|
||||
description:
|
||||
'Your recipients and full transaction history sync seamlessly across all your devices — pick up right where you left off.',
|
||||
accent: Color(0xFF8B0000),
|
||||
),
|
||||
_ValueProp(
|
||||
icon: Icons.account_balance_wallet_rounded,
|
||||
title: 'Prefund Your Wallet',
|
||||
description:
|
||||
'Top up your wallet today and use the balance whenever you need it. No rushing, no last-minute transfers.',
|
||||
accent: Color(0xFFB7791F),
|
||||
),
|
||||
];
|
||||
|
||||
if (isWide) {
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: props
|
||||
.map(
|
||||
(p) => Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: p,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: props
|
||||
.map(
|
||||
(p) =>
|
||||
Padding(padding: const EdgeInsets.only(bottom: 12), child: p),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCallToActions(
|
||||
ThemeData theme,
|
||||
ColorScheme colorScheme,
|
||||
bool isDesktop,
|
||||
) {
|
||||
final registerButton = SizedBox(
|
||||
width: double.infinity,
|
||||
height: 54,
|
||||
child: ElevatedButton(
|
||||
onPressed: _goToRegister,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('Create Free Account'),
|
||||
SizedBox(width: 8),
|
||||
Icon(Icons.arrow_forward_rounded, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final loginButton = SizedBox(
|
||||
width: double.infinity,
|
||||
height: 54,
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
context.go('/onboarding/login');
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: colorScheme.primary, width: 1.5),
|
||||
foregroundColor: colorScheme.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
child: const Text('I already have an account'),
|
||||
),
|
||||
);
|
||||
|
||||
if (isDesktop) {
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 520),
|
||||
child: Column(
|
||||
children: [registerButton, const SizedBox(height: 12), loginButton],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [registerButton, const SizedBox(height: 12), loginButton],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter(ThemeData theme, ColorScheme colorScheme) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'© Velocity · Fast, secure local payments',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.45),
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ValueProp extends StatelessWidget {
|
||||
const _ValueProp({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.description,
|
||||
required this.accent,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String description;
|
||||
final Color accent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.06),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.04),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(icon, color: accent, size: 26),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
color: colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
description,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.68),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:qpay/auth_state.dart';
|
||||
import 'package:qpay/http/http.dart';
|
||||
import 'package:qpay/models/api_response.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
@@ -60,15 +61,22 @@ class LoginController extends ChangeNotifier {
|
||||
prefs.setString('username', username);
|
||||
prefs.setString('email', response['email']);
|
||||
prefs.setString('firstName', response['firstName']);
|
||||
prefs.setString('lastName', response['lastName']);
|
||||
prefs.setString('lastName', response['lastName'] ?? '');
|
||||
prefs.setString('phone', response['phone']);
|
||||
prefs.setString('userId', response['uuid']);
|
||||
prefs.setString(
|
||||
'initials',
|
||||
response['firstName'].substring(0, 1) +
|
||||
response['lastName'].substring(0, 1),
|
||||
(response['lastName'] != null && response['lastName'].isNotEmpty
|
||||
? response['lastName'].substring(0, 1)
|
||||
: ''),
|
||||
);
|
||||
|
||||
// Notify the router that the user is now signed in so any
|
||||
// auth-gated redirects (e.g. the groups flow) are re-evaluated
|
||||
// and the user is sent to their originally requested page.
|
||||
await authState.refresh();
|
||||
|
||||
return ApiResponse.success(model);
|
||||
} else {
|
||||
model.errorMessage =
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:google_sign_in_web/web_only.dart' as web;
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qpay/screens/onboarding/login/login_controller.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
|
||||
import 'package:qpay/screens/onboarding/widgets/onboarding_layout.dart';
|
||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
@@ -20,242 +15,151 @@ class LoginScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _LoginScreenState extends State<LoginScreen> {
|
||||
late SharedPreferences prefs;
|
||||
late GoogleSignIn googleSignIn;
|
||||
late OnboardingController onboardingController;
|
||||
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final TextEditingController _emailController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
|
||||
bool _obscurePassword = true;
|
||||
bool _loading = false;
|
||||
|
||||
List<String> scopes = <String>[
|
||||
'https://www.googleapis.com/auth/contacts.readonly',
|
||||
];
|
||||
|
||||
late LoginController _loginController;
|
||||
StreamSubscription<GoogleSignInAuthenticationEvent>? _authSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loginController = LoginController(context);
|
||||
|
||||
googleSignIn = GoogleSignIn.instance;
|
||||
if (kIsWeb) {
|
||||
googleSignIn.initialize();
|
||||
_authSubscription = googleSignIn.authenticationEvents
|
||||
.handleError(_handleAuthenticationError)
|
||||
.listen(_handleAuthenticationEvent);
|
||||
} else {
|
||||
unawaited(
|
||||
googleSignIn.initialize(serverClientId: dotenv.env['CLIENTID']),
|
||||
onboardingController = Provider.of<OnboardingController>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
// Pre-fill email if it was captured during registration.
|
||||
final capturedEmail = onboardingController.model.email;
|
||||
if (capturedEmail != null && capturedEmail.isNotEmpty) {
|
||||
_emailController.text = capturedEmail;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> signInWithGoogle() async {
|
||||
if (GoogleSignIn.instance.supportsAuthenticate()) {
|
||||
_authSubscription?.cancel();
|
||||
unawaited(
|
||||
GoogleSignIn.instance.authenticate().then((value) async {
|
||||
_authSubscription = googleSignIn.authenticationEvents
|
||||
.handleError(_handleAuthenticationError)
|
||||
.listen(_handleAuthenticationEvent);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void saveUser(GoogleSignInAccount user) async {
|
||||
final Map<String, dynamic> data = <String, dynamic>{
|
||||
'displayName': user.displayName,
|
||||
'email': user.email,
|
||||
'id': user.id,
|
||||
'photoUrl': user.photoUrl,
|
||||
};
|
||||
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
if (prefs.getString("googleUser") == null) {
|
||||
prefs.setString("googleUser", jsonEncode(data));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleAuthenticationEvent(
|
||||
GoogleSignInAuthenticationEvent event,
|
||||
) async {
|
||||
if (!mounted) return;
|
||||
|
||||
// #docregion CheckAuthorization
|
||||
final GoogleSignInAccount? user = // ...
|
||||
// #enddocregion CheckAuthorization
|
||||
switch (event) {
|
||||
GoogleSignInAuthenticationEventSignIn() => event.user,
|
||||
GoogleSignInAuthenticationEventSignOut() => null,
|
||||
};
|
||||
|
||||
if (user == null || !mounted) return;
|
||||
|
||||
// Check for existing authorization.
|
||||
// #docregion CheckAuthorization
|
||||
final GoogleSignInClientAuthorization? authorization = await user
|
||||
?.authorizationClient
|
||||
.authorizationForScopes(scopes);
|
||||
// #enddocregion CheckAuthorization
|
||||
|
||||
print(user);
|
||||
saveUser(user);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
final response = await _loginController.login(user.email, user.id);
|
||||
if (response.isSuccess) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text("Login successful!"),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(response.error ?? "Login failed"),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: Colors.deepOrange,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!mounted) return;
|
||||
context.go('/');
|
||||
}
|
||||
|
||||
Future<void> _handleAuthenticationError(Object e) async {
|
||||
print(e.toString());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_authSubscription?.cancel();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_loginController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(25),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Center(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
|
||||
? ResponsivePolicy.md.toDouble()
|
||||
: constraints.maxWidth;
|
||||
return SizedBox(
|
||||
width: maxWidth,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(height: 75),
|
||||
Text(
|
||||
'Login',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5),
|
||||
Text(
|
||||
'Welcome back to Velocity',
|
||||
style: TextStyle(fontSize: 18),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 30),
|
||||
Image.asset('assets/password.png', width: 300),
|
||||
Text(
|
||||
'Tap on your preferred social media platform to get started',
|
||||
style: TextStyle(fontSize: 18),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
// Username Field
|
||||
SizedBox(height: 20),
|
||||
// Forgot Password Link
|
||||
// Divider with "or" text
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Divider(color: Colors.grey.shade300),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'choose from the options below to continue',
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Divider(color: Colors.grey.shade300),
|
||||
),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 30),
|
||||
// Social Login Buttons
|
||||
if (GoogleSignIn.instance.supportsAuthenticate())
|
||||
_buildGoogleButton()
|
||||
else ...<Widget>[if (kIsWeb) web.renderButton()],
|
||||
SizedBox(height: 40),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/');
|
||||
},
|
||||
child: Text(
|
||||
'Continue as guest?',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
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');
|
||||
}
|
||||
|
||||
Widget _buildGoogleButton() {
|
||||
return Skeletonizer(
|
||||
enabled: _loading,
|
||||
child: Row(
|
||||
if (!mounted) return;
|
||||
|
||||
if (response.isSuccess) {
|
||||
context.go('/');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OnboardingLayout(
|
||||
showBackButton: true,
|
||||
trailingLabel: 'Sign up',
|
||||
trailingRoute: '/onboarding/phone',
|
||||
trailingIcon: Icons.person_add_alt_1_rounded,
|
||||
bottomLinkLabel: 'Continue as guest?',
|
||||
bottomLinkRoute: '/',
|
||||
child: OnboardingCard(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
signInWithGoogle();
|
||||
const OnboardingHero(
|
||||
icon: Icons.lock_open_rounded,
|
||||
title: 'Welcome back',
|
||||
subtitle: 'Sign in to continue with Velocity',
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
OnboardingTextField(
|
||||
controller: _emailController,
|
||||
hintText: 'Email address',
|
||||
prefixIcon: Icons.email_outlined,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Please enter your email address';
|
||||
}
|
||||
final emailRegex = RegExp(r'^[\w\.\-+]+@[\w\.\-]+\.\w+$');
|
||||
if (!emailRegex.hasMatch(value.trim())) {
|
||||
return 'Please enter a valid email address';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
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),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,50 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:qpay/http/http.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class OnboardingModel {
|
||||
class RegistrationModel {
|
||||
String? email;
|
||||
String? firstName;
|
||||
String? lastName;
|
||||
String? phone;
|
||||
String? password;
|
||||
String? workflowId;
|
||||
String? username;
|
||||
GoogleSignInAccount? googleUser;
|
||||
bool isLoading = false;
|
||||
}
|
||||
|
||||
class OnboardingController extends ChangeNotifier {
|
||||
OnboardingModel model = OnboardingModel();
|
||||
RegistrationModel model = RegistrationModel();
|
||||
|
||||
void resetState() {
|
||||
model = OnboardingModel();
|
||||
model = RegistrationModel();
|
||||
}
|
||||
|
||||
void updateModel(Map mapModel) {
|
||||
model.workflowId = mapModel['workflowId'];
|
||||
model.phone = mapModel['phone'];
|
||||
model.username = mapModel['username'];
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
updatePhone(String phone) {
|
||||
void updateEmail(String email) {
|
||||
model.email = email;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateFirstName(String firstName) {
|
||||
model.firstName = firstName;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateLastName(String lastName) {
|
||||
model.lastName = lastName;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updatePhone(String phone) {
|
||||
model.phone = phone;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
updateGoogleUser(GoogleSignInAccount googleUser) {
|
||||
model.googleUser = googleUser;
|
||||
void updatePassword(String password) {
|
||||
model.password = password;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
class PasswordScreen extends StatefulWidget {
|
||||
const PasswordScreen({super.key});
|
||||
|
||||
@override
|
||||
State<PasswordScreen> createState() => _PasswordScreenState();
|
||||
}
|
||||
|
||||
class _PasswordScreenState extends State<PasswordScreen> {
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final TextEditingController _confirmPasswordController =
|
||||
TextEditingController();
|
||||
final FocusNode _passwordFocusNode = FocusNode();
|
||||
final FocusNode _confirmPasswordFocusNode = FocusNode();
|
||||
bool _obscurePassword = true;
|
||||
bool _obscureConfirmPassword = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
_passwordFocusNode.dispose();
|
||||
_confirmPasswordFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(25),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(height: 75),
|
||||
Text(
|
||||
'Set New Password',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5),
|
||||
Text(
|
||||
'Create a strong password to secure your account',
|
||||
style: TextStyle(fontSize: 18),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Image.asset('assets/password.png', width: 300),
|
||||
// Password Input
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
focusNode: _passwordFocusNode,
|
||||
obscureText: _obscurePassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'New Password',
|
||||
hintText: 'Enter your new password',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
// Confirm Password Input
|
||||
TextField(
|
||||
controller: _confirmPasswordController,
|
||||
focusNode: _confirmPasswordFocusNode,
|
||||
obscureText: _obscureConfirmPassword,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Confirm Password',
|
||||
hintText: 'Confirm your new password',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscureConfirmPassword
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscureConfirmPassword = !_obscureConfirmPassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
// Password Requirements
|
||||
Container(
|
||||
padding: EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: Colors.grey.shade200),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Password Requirements:',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
_buildRequirement('At least 8 characters', true),
|
||||
_buildRequirement('Contains uppercase letter', true),
|
||||
_buildRequirement('Contains lowercase letter', true),
|
||||
_buildRequirement('Contains number', true),
|
||||
_buildRequirement('Contains special character', false),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SizedBox(
|
||||
height: 130,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 25),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
// TODO: Add password update logic
|
||||
String password = _passwordController.text;
|
||||
String confirmPassword =
|
||||
_confirmPasswordController.text;
|
||||
print('Password: $password');
|
||||
print('Confirm Password: $confirmPassword');
|
||||
|
||||
context.go('/onboarding/login');
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Update Password',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
context.go('/onboarding/login');
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
side: BorderSide(color: Colors.grey.shade300),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
'Back to Login',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/');
|
||||
},
|
||||
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRequirement(String text, bool isMet) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isMet ? Icons.check_circle : Icons.circle_outlined,
|
||||
size: 16,
|
||||
color: isMet ? Colors.green : Colors.grey.shade600,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: isMet ? Colors.grey.shade600 : Colors.grey.shade400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -35,27 +35,26 @@ class PhoneController extends AbstractController {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
Future<ApiResponse<Map<String, dynamic>>> register() async {
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
|
||||
Map user = {
|
||||
"username": onboardingController.model.googleUser?.email,
|
||||
"email": onboardingController.model.googleUser?.email,
|
||||
"firstName": onboardingController.model.googleUser?.displayName?.split(" ").first,
|
||||
"lastName": onboardingController.model.googleUser?.displayName?.split(" ").last,
|
||||
"password": onboardingController.model.googleUser?.id,
|
||||
"photoUrl": onboardingController.model.googleUser?.photoUrl,
|
||||
"username": onboardingController.model.email,
|
||||
"email": onboardingController.model.email,
|
||||
"firstName": onboardingController.model.firstName,
|
||||
"lastName": onboardingController.model.lastName,
|
||||
"password": onboardingController.model.password,
|
||||
"phone": onboardingController.model.phone,
|
||||
"tempUid": prefs.get("userId")
|
||||
"tempUid": prefs.get("userId"),
|
||||
};
|
||||
|
||||
try {
|
||||
model.isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
Map response = await http.post(
|
||||
'/auth/register',
|
||||
user
|
||||
user,
|
||||
);
|
||||
model.status = response['state'];
|
||||
if (response['state'] == 'failed') {
|
||||
@@ -79,8 +78,9 @@ class PhoneController extends AbstractController {
|
||||
);
|
||||
model.isLoading = false;
|
||||
notifyListeners();
|
||||
return ApiResponse.failure("Problem during registration, please try again or contact support?");
|
||||
return ApiResponse.failure(
|
||||
"Problem during registration, please try again or contact support?",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_contacts/flutter_contacts.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
|
||||
import 'package:qpay/screens/onboarding/phone/phone_controller.dart';
|
||||
import 'package:qpay/screens/onboarding/widgets/onboarding_layout.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
|
||||
class PhoneScreen extends StatefulWidget {
|
||||
@@ -14,10 +15,18 @@ class PhoneScreen extends StatefulWidget {
|
||||
|
||||
class _PhoneScreenState extends State<PhoneScreen> {
|
||||
late PhoneController phoneController;
|
||||
late OnboardingController onboardingController;
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final TextEditingController _phoneController = TextEditingController();
|
||||
final TextEditingController _emailController = TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final TextEditingController _confirmPasswordController =
|
||||
TextEditingController();
|
||||
final TextEditingController _fullNameController = TextEditingController();
|
||||
|
||||
String selectedCountryCode = '+263'; // Default to ZW
|
||||
bool _obscurePassword = true;
|
||||
bool _obscureConfirmPassword = true;
|
||||
|
||||
// Common country codes with flags and names
|
||||
final List<Map<String, String>> countryCodes = [
|
||||
@@ -48,27 +57,81 @@ class _PhoneScreenState extends State<PhoneScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
phoneController = PhoneController(context);
|
||||
onboardingController = Provider.of<OnboardingController>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
// Pre-fill the email field if it has already been captured.
|
||||
final existingEmail = onboardingController.model.email;
|
||||
if (existingEmail != null && existingEmail.isNotEmpty) {
|
||||
_emailController.text = existingEmail;
|
||||
}
|
||||
// Pre-fill the password field if it has already been captured.
|
||||
final existingPassword = onboardingController.model.password;
|
||||
if (existingPassword != null && existingPassword.isNotEmpty) {
|
||||
_passwordController.text = existingPassword;
|
||||
_confirmPasswordController.text = existingPassword;
|
||||
}
|
||||
// Pre-fill the full name from firstName + lastName if previously captured.
|
||||
final firstName = onboardingController.model.firstName;
|
||||
final lastName = onboardingController.model.lastName;
|
||||
final combined = [
|
||||
if (firstName != null && firstName.isNotEmpty) firstName,
|
||||
if (lastName != null && lastName.isNotEmpty) lastName,
|
||||
].join(' ');
|
||||
if (combined.isNotEmpty) {
|
||||
_fullNameController.text = combined;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updatePhoneNumber(String phoneNumber) async {
|
||||
String existingPhone = phoneNumber;
|
||||
if (existingPhone.isNotEmpty) {
|
||||
// Try to extract country code from existing phone number
|
||||
for (var country in countryCodes) {
|
||||
if (existingPhone.startsWith(country['code']!)) {
|
||||
setState(() {
|
||||
selectedCountryCode = country['code']!;
|
||||
});
|
||||
_phoneController.text = existingPhone.substring(
|
||||
country['code']!.length,
|
||||
);
|
||||
break;
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneController.dispose();
|
||||
_emailController.dispose();
|
||||
_passwordController.dispose();
|
||||
_confirmPasswordController.dispose();
|
||||
_fullNameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Splits a full name into a [firstName] and [lastName] using a single
|
||||
/// space as the delimiter.
|
||||
///
|
||||
/// Examples:
|
||||
/// "John" -> firstName: "John", lastName: ""
|
||||
/// "John Doe" -> firstName: "John", lastName: "Doe"
|
||||
/// "John Michael Doe" -> firstName: "John", lastName: "Michael Doe"
|
||||
/// " John Doe " -> firstName: "John", lastName: "Doe"
|
||||
({String firstName, String lastName}) splitFullName(String fullName) {
|
||||
final trimmed = fullName.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return (firstName: '', lastName: '');
|
||||
}
|
||||
// If no country code found, use default and set the full number
|
||||
if (_phoneController.text.isEmpty) {
|
||||
_phoneController.text = existingPhone;
|
||||
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,148 +140,171 @@ class _PhoneScreenState extends State<PhoneScreen> {
|
||||
return ListenableBuilder(
|
||||
listenable: phoneController,
|
||||
builder: (context, child) {
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(25.0),
|
||||
return OnboardingLayout(
|
||||
showBackButton: true,
|
||||
trailingLabel: 'Sign in',
|
||||
trailingRoute: '/onboarding/login',
|
||||
trailingIcon: Icons.login_rounded,
|
||||
bottomLinkLabel: 'Continue as guest?',
|
||||
bottomLinkRoute: '/',
|
||||
child: OnboardingCard(
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Center(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth =
|
||||
constraints.maxWidth > ResponsivePolicy.md
|
||||
? ResponsivePolicy.md.toDouble()
|
||||
: constraints.maxWidth;
|
||||
return SizedBox(
|
||||
width: maxWidth,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
SizedBox(height: 75),
|
||||
Text(
|
||||
'Verify Your Device',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
Text(
|
||||
'We will send you a verification code to this number',
|
||||
style: TextStyle(fontSize: 18),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
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(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
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');
|
||||
const OnboardingHero(
|
||||
icon: Icons.person_add_alt_1_rounded,
|
||||
title: 'Create your account',
|
||||
subtitle:
|
||||
'It only takes a minute — we\'ll send a verification code to confirm your details.',
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
OnboardingTextField(
|
||||
controller: _fullNameController,
|
||||
hintText: 'Full name',
|
||||
prefixIcon: Icons.person_outline,
|
||||
textCapitalization: TextCapitalization.words,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Please enter your full name';
|
||||
}
|
||||
if (value.trim().length < 2) {
|
||||
return 'Name is too short';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
child: Text(
|
||||
'Send Code',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_buildPhoneField(),
|
||||
const SizedBox(height: 14),
|
||||
OnboardingTextField(
|
||||
controller: _emailController,
|
||||
hintText: 'Email address',
|
||||
prefixIcon: Icons.email_outlined,
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
validator: (value) {
|
||||
if (value == null || value.trim().isEmpty) {
|
||||
return 'Please enter your email address';
|
||||
}
|
||||
final emailRegex = RegExp(r'^[\w\.\-+]+@[\w\.\-]+\.\w+$');
|
||||
if (!emailRegex.hasMatch(value.trim())) {
|
||||
return 'Please enter a valid email address';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
OnboardingTextField(
|
||||
controller: _passwordController,
|
||||
hintText: 'Create a password',
|
||||
prefixIcon: Icons.lock_outline,
|
||||
obscureText: _obscurePassword,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility_rounded
|
||||
: Icons.visibility_off_rounded,
|
||||
),
|
||||
SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
tooltip: _obscurePassword
|
||||
? 'Show password'
|
||||
: 'Hide password',
|
||||
onPressed: () {
|
||||
context.go('/onboarding/login');
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
child: Text(
|
||||
'Go to Login',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please enter a password';
|
||||
}
|
||||
if (value.length < 8) {
|
||||
return 'Password must be at least 8 characters';
|
||||
}
|
||||
if (value.length > 128) {
|
||||
return 'Password is too long';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
OnboardingTextField(
|
||||
controller: _confirmPasswordController,
|
||||
hintText: 'Confirm your password',
|
||||
prefixIcon: Icons.lock_outline,
|
||||
obscureText: _obscureConfirmPassword,
|
||||
enableSuggestions: false,
|
||||
autocorrect: false,
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscureConfirmPassword
|
||||
? Icons.visibility_rounded
|
||||
: Icons.visibility_off_rounded,
|
||||
),
|
||||
tooltip: _obscureConfirmPassword
|
||||
? 'Show password'
|
||||
: 'Hide password',
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscureConfirmPassword = !_obscureConfirmPassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return 'Please confirm your password';
|
||||
}
|
||||
if (value != _passwordController.text) {
|
||||
return 'Passwords do not match';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
Skeletonizer(
|
||||
enabled: phoneController.model.isLoading,
|
||||
child: OnboardingPrimaryButton(
|
||||
label: 'Send verification code',
|
||||
icon: Icons.arrow_forward_rounded,
|
||||
onPressed: _onSubmit,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/');
|
||||
},
|
||||
child: Text(
|
||||
'Continue as guest?',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPhoneField() {
|
||||
return Column(
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
// Country code dropdown
|
||||
Container(
|
||||
width: 100,
|
||||
height: 55,
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Container(
|
||||
height: 47,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.primary.withOpacity(0.2),
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.12),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String>(
|
||||
value: selectedCountryCode,
|
||||
isExpanded: true,
|
||||
icon: const Icon(Icons.arrow_drop_down),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
icon: const Icon(Icons.arrow_drop_down_rounded),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
items: countryCodes.map((country) {
|
||||
return DropdownMenuItem<String>(
|
||||
value: country['code'],
|
||||
@@ -226,10 +312,13 @@ class _PhoneScreenState extends State<PhoneScreen> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(country['flag']!),
|
||||
const SizedBox(width: 4),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
country['code']!,
|
||||
style: const TextStyle(fontSize: 16),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -245,35 +334,21 @@ class _PhoneScreenState extends State<PhoneScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// Phone number field
|
||||
Expanded(
|
||||
child: TextFormField(
|
||||
child: OnboardingTextField(
|
||||
controller: _phoneController,
|
||||
hintText: 'Phone number',
|
||||
prefixIcon: Icons.phone_outlined,
|
||||
keyboardType: TextInputType.numberWithOptions(decimal: true),
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Enter phone number',
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.primary.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
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]'), '');
|
||||
final digitsOnly = value.replaceAll(RegExp(r'[^\d]'), '');
|
||||
if (digitsOnly.length < 7) {
|
||||
return 'Phone number must be at least 7 digits';
|
||||
}
|
||||
@@ -285,8 +360,6 @@ class _PhoneScreenState extends State<PhoneScreen> {
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,276 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_sign_in/google_sign_in.dart';
|
||||
import 'package:google_sign_in_web/web_only.dart' as web;
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/onboarding/onboarding_controller.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
|
||||
class RegisterScreen extends StatefulWidget {
|
||||
const RegisterScreen({super.key});
|
||||
|
||||
@override
|
||||
State<RegisterScreen> createState() => _RegisterScreenState();
|
||||
}
|
||||
|
||||
class _RegisterScreenState extends State<RegisterScreen> {
|
||||
late OnboardingController onboardingController;
|
||||
late GoogleSignIn googleSignIn;
|
||||
|
||||
bool _loading = false;
|
||||
|
||||
List<String> scopes = <String>[
|
||||
'https://www.googleapis.com/auth/contacts.readonly',
|
||||
];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
onboardingController = Provider.of<OnboardingController>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
// onboardingController.resetState();
|
||||
|
||||
googleSignIn = GoogleSignIn.instance;
|
||||
if(kIsWeb){
|
||||
googleSignIn.initialize();
|
||||
googleSignIn.authenticationEvents
|
||||
.listen(_handleAuthenticationEvent)
|
||||
.onError(_handleAuthenticationError);
|
||||
|
||||
}else {
|
||||
unawaited(googleSignIn.initialize(serverClientId: dotenv.env['CLIENTID']));
|
||||
}
|
||||
}
|
||||
|
||||
void signInWithGoogle() {
|
||||
if (GoogleSignIn.instance.supportsAuthenticate()){
|
||||
unawaited(GoogleSignIn.instance.authenticate().then((value) {
|
||||
googleSignIn.authenticationEvents
|
||||
.listen(_handleAuthenticationEvent)
|
||||
.onError(_handleAuthenticationError);
|
||||
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleAuthenticationEvent(GoogleSignInAuthenticationEvent event) async {
|
||||
// #docregion CheckAuthorization
|
||||
final GoogleSignInAccount? user = // ...
|
||||
// #enddocregion CheckAuthorization
|
||||
switch (event) {
|
||||
GoogleSignInAuthenticationEventSignIn() => event.user,
|
||||
GoogleSignInAuthenticationEventSignOut() => null,
|
||||
};
|
||||
|
||||
// Check for existing authorization.
|
||||
// #docregion CheckAuthorization
|
||||
final GoogleSignInClientAuthorization? authorization = await user
|
||||
?.authorizationClient
|
||||
.authorizationForScopes(scopes);
|
||||
// #enddocregion CheckAuthorization
|
||||
print(user);
|
||||
onboardingController.updateGoogleUser(user as GoogleSignInAccount);
|
||||
|
||||
}
|
||||
|
||||
Future<void> _handleAuthenticationError(Object e) async {
|
||||
print(e.toString());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: onboardingController,
|
||||
builder: (context, child) {
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(25),
|
||||
child: Center(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
|
||||
? ResponsivePolicy.md.toDouble()
|
||||
: constraints.maxWidth;
|
||||
return SizedBox(
|
||||
width: maxWidth,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(height: 75),
|
||||
Text(
|
||||
'Registration',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5),
|
||||
Text(
|
||||
"Let's start by getting some of your online details to get you started",
|
||||
style: TextStyle(fontSize: 18),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Image.asset('assets/social.png', width: 300),
|
||||
SizedBox(height: 20),
|
||||
|
||||
// Social Login Buttons
|
||||
if(onboardingController.model.googleUser == null)
|
||||
Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: Divider(color: Colors.grey.shade300)),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Text(
|
||||
'Choose from the options below to continue',
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 14,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
Expanded(child: Divider(color: Colors.grey.shade300)),
|
||||
],
|
||||
),
|
||||
SizedBox(height: 20),
|
||||
if(GoogleSignIn.instance.supportsAuthenticate())
|
||||
_buildGoogleButton()
|
||||
else ...<Widget>[
|
||||
if (kIsWeb)
|
||||
web.renderButton()
|
||||
],
|
||||
],
|
||||
),
|
||||
if(onboardingController.model.googleUser != null)
|
||||
Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 100,
|
||||
child: Divider(
|
||||
color: Colors.grey.shade300,
|
||||
thickness: 1,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 20,),
|
||||
Text(
|
||||
"Welcome, ${onboardingController.model.googleUser?.displayName}",
|
||||
style: TextStyle(fontSize: 25),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
SizedBox(height: 10,),
|
||||
Text("Tap Proceed to continue", style: TextStyle(fontSize: 18),)
|
||||
],
|
||||
),
|
||||
SizedBox(height: 50),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 25),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
if (onboardingController.model.googleUser == null) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: const Text('Sign-In Required'),
|
||||
content: const Text(
|
||||
"Please sign in with one of the social media platforms to proceed."),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
child: const Text('OK'),
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop(); // Close the dialog
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
context.go('/onboarding/phone');
|
||||
}
|
||||
},
|
||||
child: Text('Proceed', style: TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
context.go('/onboarding/login');
|
||||
},
|
||||
child: Text(
|
||||
'Go to Login',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/');
|
||||
},
|
||||
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGoogleButton(){
|
||||
return Skeletonizer(
|
||||
enabled: _loading,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
signInWithGoogle();
|
||||
},
|
||||
icon: Image.asset(
|
||||
'assets/google.png',
|
||||
width: 20,
|
||||
height: 20,
|
||||
),
|
||||
label: Text('Google', style: TextStyle(fontSize: 16)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
side: BorderSide(color: Colors.grey.shade300),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:qpay/abstracts/AbstractController.dart';
|
||||
@@ -31,9 +34,64 @@ class VerifyController extends AbstractController {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Extracts a human-readable error message from any exception thrown during
|
||||
/// an HTTP call. Supports:
|
||||
/// * [DioException] – the response body, request body, and a top-level
|
||||
/// `message` field are inspected.
|
||||
/// * Dart [Exception] / [Error] whose stringified form is a JSON object
|
||||
/// (e.g. `{state: failed, status: manual, body: {...}, message: ...}`).
|
||||
/// * Any other throwable – falls back to its `toString()`.
|
||||
String _extractErrorMessage(Object error) {
|
||||
try {
|
||||
if (error is DioException) {
|
||||
final data = error.response?.data;
|
||||
if (data is Map) {
|
||||
final message = data['message'];
|
||||
if (message is String && message.isNotEmpty) return message;
|
||||
}
|
||||
if (data is String && data.isNotEmpty) {
|
||||
final parsed = _tryParseJson(data);
|
||||
if (parsed is Map) {
|
||||
final message = parsed['message'];
|
||||
if (message is String && message.isNotEmpty) return message;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
if (error.type == DioExceptionType.connectionTimeout ||
|
||||
error.type == DioExceptionType.receiveTimeout ||
|
||||
error.type == DioExceptionType.sendTimeout) {
|
||||
return 'The request timed out. Please check your connection and try again.';
|
||||
}
|
||||
if (error.type == DioExceptionType.connectionError) {
|
||||
return 'No internet connection. Please check your network and try again.';
|
||||
}
|
||||
return error.message ?? 'Network error. Please try again.';
|
||||
}
|
||||
|
||||
final raw = error.toString();
|
||||
// The server sometimes throws a stringified JSON object as an exception.
|
||||
final parsed = _tryParseJson(raw);
|
||||
if (parsed is Map) {
|
||||
final message = parsed['message'];
|
||||
if (message is String && message.isNotEmpty) return message;
|
||||
}
|
||||
return raw;
|
||||
} catch (_) {
|
||||
return 'An unexpected error occurred. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
dynamic _tryParseJson(String input) {
|
||||
try {
|
||||
return jsonDecode(input);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<ApiResponse<Map<String, dynamic>>> verifyOtp() async {
|
||||
Map user = {
|
||||
"username": onboardingController.model.googleUser?.email,
|
||||
"username": onboardingController.model.email,
|
||||
"otpCode": model.code,
|
||||
"otpType": "REGISTRATION",
|
||||
"workflowId": onboardingController.model.workflowId,
|
||||
@@ -41,62 +99,61 @@ class VerifyController extends AbstractController {
|
||||
|
||||
try {
|
||||
model.isLoading = true;
|
||||
model.errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
Map response = await http.post(
|
||||
'/auth/verify',
|
||||
user
|
||||
);
|
||||
Map response = await http.post('/auth/verify', user);
|
||||
model.status = response['state'];
|
||||
model.isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
if (response['state'] == 'failed') {
|
||||
model.errorMessage = response['message'];
|
||||
return ApiResponse.failure(response['message'] ?? "Verification failed");
|
||||
final message =
|
||||
response['message']?.toString() ?? "Verification failed";
|
||||
model.errorMessage = message;
|
||||
return ApiResponse.failure(message);
|
||||
}
|
||||
return ApiResponse.success(Map<String, dynamic>.from(response));
|
||||
} catch (e) {
|
||||
logger.e(e);
|
||||
final message = _extractErrorMessage(e);
|
||||
model.errorMessage = message;
|
||||
model.isLoading = false;
|
||||
notifyListeners();
|
||||
return ApiResponse.failure(
|
||||
"Problem during registration, please try again or contact support?",
|
||||
);
|
||||
return ApiResponse.failure(message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<ApiResponse<Map<String, dynamic>>> resendOtp() async {
|
||||
Map user = {
|
||||
"username": onboardingController.model.googleUser?.email,
|
||||
"username": onboardingController.model.email,
|
||||
"phone": onboardingController.model.phone,
|
||||
"workflowId": onboardingController.model.workflowId,
|
||||
};
|
||||
|
||||
try {
|
||||
model.isLoading = true;
|
||||
model.errorMessage = null;
|
||||
notifyListeners();
|
||||
|
||||
Map response = await http.post(
|
||||
'/auth/resend',
|
||||
user
|
||||
);
|
||||
Map response = await http.post('/auth/resend', user);
|
||||
model.status = response['state'];
|
||||
model.isLoading = false;
|
||||
notifyListeners();
|
||||
|
||||
if (response['state'] == 'failed') {
|
||||
model.errorMessage = response['message'];
|
||||
return ApiResponse.failure(response['message'] ?? "Resend failed");
|
||||
final message = response['message']?.toString() ?? "Resend failed";
|
||||
model.errorMessage = message;
|
||||
return ApiResponse.failure(message);
|
||||
}
|
||||
return ApiResponse.success(Map<String, dynamic>.from(response));
|
||||
} catch (e) {
|
||||
logger.e(e);
|
||||
final message = _extractErrorMessage(e);
|
||||
model.errorMessage = message;
|
||||
model.isLoading = false;
|
||||
notifyListeners();
|
||||
return ApiResponse.failure(
|
||||
"Problem during registration, please try again or contact support?",
|
||||
);
|
||||
return ApiResponse.failure(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/onboarding/verify/verify_controller.dart';
|
||||
import 'package:qpay/screens/onboarding/widgets/onboarding_layout.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
|
||||
class VerifyScreen extends StatefulWidget {
|
||||
@@ -18,7 +18,6 @@ class _VerifyScreenState extends State<VerifyScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
verifyController = VerifyController(context);
|
||||
}
|
||||
|
||||
@@ -48,122 +47,12 @@ class _VerifyScreenState extends State<VerifyScreen> {
|
||||
void _onCodeChanged(String value, int index) {
|
||||
if (value.length == 1 && index < 5) {
|
||||
_focusNodes[index + 1].requestFocus();
|
||||
} else if (value.isEmpty && index > 0) {
|
||||
_focusNodes[index - 1].requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: verifyController,
|
||||
builder: (context, child) {
|
||||
return Scaffold(
|
||||
body: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(25),
|
||||
child: Center(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxWidth = constraints.maxWidth > ResponsivePolicy.md
|
||||
? ResponsivePolicy.md.toDouble()
|
||||
: constraints.maxWidth;
|
||||
return SizedBox(
|
||||
width: maxWidth,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(height: 75),
|
||||
Text(
|
||||
'Verify Your Account',
|
||||
style: TextStyle(
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5),
|
||||
Text(
|
||||
'Enter the 6-digit code sent to your phone/email',
|
||||
style: TextStyle(fontSize: 18),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
Image.asset('assets/verify.png', width: 300),
|
||||
// Verification Code Input
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: List.generate(
|
||||
6,
|
||||
(index) => SizedBox(
|
||||
width: 50,
|
||||
child: TextFormField(
|
||||
controller: _codeControllers[index],
|
||||
focusNode: _focusNodes[index],
|
||||
textAlign: TextAlign.center,
|
||||
keyboardType: TextInputType.number,
|
||||
maxLength: 1,
|
||||
decoration: InputDecoration(
|
||||
counterText: '',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
contentPadding: EdgeInsets.symmetric(vertical: 16),
|
||||
),
|
||||
onChanged: (value) => _onCodeChanged(value, index),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(height: 5),
|
||||
if(errorMessage != null)
|
||||
Text(errorMessage ?? '', style: TextStyle(color: Colors.red)),
|
||||
SizedBox(height: 30),
|
||||
// Resend Code Section
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"Didn't receive the code? ",
|
||||
style: TextStyle(color: Colors.grey.shade600, fontSize: 16),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
verifyController.resendOtp();
|
||||
},
|
||||
child: Text(
|
||||
'Resend Code',
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
// SizedBox(height: 20),
|
||||
// // Timer for resend (optional)
|
||||
// Text(
|
||||
// 'Resend available in 2:30',
|
||||
// style: TextStyle(color: Colors.grey.shade500, fontSize: 14),
|
||||
// ),
|
||||
SizedBox(height: 40),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 25),
|
||||
child: Skeletonizer(
|
||||
enabled: verifyController.model.isLoading,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
Future<void> _submitCode() async {
|
||||
showErrorMessage(null);
|
||||
String code = '';
|
||||
for (var controller in _codeControllers) {
|
||||
@@ -175,51 +64,167 @@ class _VerifyScreenState extends State<VerifyScreen> {
|
||||
return;
|
||||
}
|
||||
verifyController.updateCode(code);
|
||||
await verifyController.verifyOtp();
|
||||
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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListenableBuilder(
|
||||
listenable: verifyController,
|
||||
builder: (context, child) {
|
||||
return OnboardingLayout(
|
||||
showBackButton: true,
|
||||
trailingLabel: 'Sign in',
|
||||
trailingRoute: '/onboarding/login',
|
||||
trailingIcon: Icons.login_rounded,
|
||||
bottomLinkLabel: 'Continue as guest?',
|
||||
bottomLinkRoute: '/',
|
||||
child: OnboardingCard(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const OnboardingHero(
|
||||
icon: Icons.sms_rounded,
|
||||
title: 'Verify your account',
|
||||
subtitle:
|
||||
'Enter the 6-digit code we sent to your phone or email.',
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
_buildCodeInputs(),
|
||||
const SizedBox(height: 16),
|
||||
if (errorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Verify Code',
|
||||
style: TextStyle(fontSize: 16),
|
||||
errorMessage!,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
context.go('/onboarding/login');
|
||||
},
|
||||
child: Text(
|
||||
'Back to Login',
|
||||
style: TextStyle(fontSize: 16),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.go('/');
|
||||
verifyController.resendOtp();
|
||||
},
|
||||
child: Text('Continue as guest?', style: TextStyle(fontSize: 14)),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colorScheme.primary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
child: const Text('Resend code'),
|
||||
),
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
611
lib/screens/onboarding/widgets/onboarding_layout.dart
Normal file
611
lib/screens/onboarding/widgets/onboarding_layout.dart
Normal file
@@ -0,0 +1,611 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
|
||||
/// Shared layout for the onboarding flow (login, register, verify, complete).
|
||||
///
|
||||
/// Provides the consistent Velocity look & feel:
|
||||
/// - Soft brand gradient background
|
||||
/// - Top bar with the Velocity brand mark and contextual trailing action
|
||||
/// - Centered content card (form area)
|
||||
/// - Bottom "Continue as guest?" + footer
|
||||
///
|
||||
/// All screens wrap their content with this layout so the flow feels cohesive
|
||||
/// across mobile and web.
|
||||
class OnboardingLayout extends StatelessWidget {
|
||||
const OnboardingLayout({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.trailingLabel,
|
||||
this.trailingRoute,
|
||||
this.trailingIcon,
|
||||
this.showBackButton = false,
|
||||
this.onBack,
|
||||
this.bottomLinkLabel,
|
||||
this.bottomLinkRoute,
|
||||
this.bottomLinkOnPressed,
|
||||
});
|
||||
|
||||
/// The body of the screen (typically a form inside an [OnboardingCard]).
|
||||
final Widget child;
|
||||
|
||||
/// Optional label for the trailing action in the top bar
|
||||
/// (e.g. "Sign in", "Back"). Hidden if null.
|
||||
final String? trailingLabel;
|
||||
|
||||
/// Route to navigate to when the trailing action is pressed.
|
||||
final String? trailingRoute;
|
||||
|
||||
/// Optional icon for the trailing action (defaults to chevron/arrow).
|
||||
final IconData? trailingIcon;
|
||||
|
||||
/// If true, shows a back-arrow icon button on the leading side of the top bar.
|
||||
final bool showBackButton;
|
||||
|
||||
/// Optional callback for the back button. If not provided, the button pops
|
||||
/// the route via [GoRouter].
|
||||
final VoidCallback? onBack;
|
||||
|
||||
/// Optional label for a link rendered below the main content
|
||||
/// (e.g. "Continue as guest?").
|
||||
final String? bottomLinkLabel;
|
||||
|
||||
/// Route to navigate to when the bottom link is pressed.
|
||||
final String? bottomLinkRoute;
|
||||
|
||||
/// Optional callback for the bottom link (overrides [bottomLinkRoute]).
|
||||
final VoidCallback? bottomLinkOnPressed;
|
||||
|
||||
// Max content width on large screens (desktop / web) for the page chrome
|
||||
// (top bar, footer, etc.).
|
||||
static const double _maxContentWidth = 1200;
|
||||
// Max content width on tablet — the current mobile cap lifted slightly
|
||||
// so the page breathes a bit on landscape phones / small tablets.
|
||||
static const double _maxTabletWidth = 760;
|
||||
// Max width for the form card on desktop — keeps forms a comfortable
|
||||
// reading/interaction width on wide screens.
|
||||
static const double _formMaxWidth = 480;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
final isTablet = width >= ResponsivePolicy.md;
|
||||
final isDesktop = width >= ResponsivePolicy.lg;
|
||||
|
||||
return Scaffold(
|
||||
// Apply the gradient to the Scaffold body itself via a Container so
|
||||
// it is guaranteed to cover the full viewport (including the area
|
||||
// under the SafeArea) regardless of how tall the form content ends
|
||||
// up being. The previous Stack + Positioned.fill version shrunk to
|
||||
// fit the non-positioned SingleChildScrollView, which left an
|
||||
// unstyled area at the bottom of the page.
|
||||
body: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
colorScheme.primary.withValues(alpha: 0.08),
|
||||
colorScheme.surface,
|
||||
colorScheme.secondary.withValues(alpha: 0.05),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, viewportConstraints) {
|
||||
final horizontalPadding = isDesktop ? 48.0 : 20.0;
|
||||
final shellWidth = viewportConstraints.maxWidth < _maxContentWidth
|
||||
? viewportConstraints.maxWidth
|
||||
: _maxContentWidth;
|
||||
final availableContentWidth =
|
||||
(shellWidth - (horizontalPadding * 2)).clamp(
|
||||
0.0,
|
||||
double.infinity,
|
||||
);
|
||||
final contentMaxWidth = isDesktop
|
||||
? _maxContentWidth
|
||||
: (isTablet
|
||||
? (_maxTabletWidth < availableContentWidth
|
||||
? _maxTabletWidth
|
||||
: availableContentWidth)
|
||||
: availableContentWidth);
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: viewportConstraints.maxHeight,
|
||||
),
|
||||
child: IntrinsicHeight(
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: _maxContentWidth,
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: horizontalPadding,
|
||||
vertical: isDesktop ? 32 : 16,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: contentMaxWidth,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const SizedBox(height: 4),
|
||||
_buildTopBar(
|
||||
context,
|
||||
theme,
|
||||
colorScheme,
|
||||
isDesktop,
|
||||
),
|
||||
SizedBox(height: isDesktop ? 32 : 20),
|
||||
// On desktop, narrow the form to a
|
||||
// comfortable width and center it. On
|
||||
// mobile/tablet, let it expand to fill
|
||||
// the available space.
|
||||
isDesktop
|
||||
? Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: _formMaxWidth,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
)
|
||||
: child,
|
||||
if (bottomLinkLabel != null ||
|
||||
bottomLinkOnPressed != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildBottomLink(context, theme, colorScheme),
|
||||
],
|
||||
const Spacer(),
|
||||
_buildFooter(theme, colorScheme),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopBar(
|
||||
BuildContext context,
|
||||
ThemeData theme,
|
||||
ColorScheme colorScheme,
|
||||
bool isDesktop,
|
||||
) {
|
||||
return Row(
|
||||
children: [
|
||||
if (showBackButton)
|
||||
IconButton(
|
||||
onPressed:
|
||||
onBack ??
|
||||
() {
|
||||
if (context.canPop()) {
|
||||
context.pop();
|
||||
} else {
|
||||
context.go('/onboarding/landing');
|
||||
}
|
||||
},
|
||||
icon: Icon(Icons.arrow_back_rounded, color: colorScheme.onSurface),
|
||||
tooltip: 'Back',
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: colorScheme.surface,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
GestureDetector(
|
||||
onTap: () => context.go('/onboarding/landing'),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primary.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.bolt_rounded,
|
||||
color: colorScheme.primary,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Velocity',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
color: colorScheme.primary,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (trailingLabel != null && trailingRoute != null)
|
||||
TextButton(
|
||||
onPressed: () => context.go(trailingRoute!),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: colorScheme.primary,
|
||||
padding: EdgeInsets.symmetric(horizontal: isDesktop ? 16 : 8),
|
||||
textStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (trailingIcon != null) ...[
|
||||
Icon(trailingIcon, size: 18),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Text(trailingLabel!),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomLink(
|
||||
BuildContext context,
|
||||
ThemeData theme,
|
||||
ColorScheme colorScheme,
|
||||
) {
|
||||
return Center(
|
||||
child: TextButton(
|
||||
onPressed:
|
||||
bottomLinkOnPressed ??
|
||||
() {
|
||||
if (bottomLinkRoute != null) context.go(bottomLinkRoute!);
|
||||
},
|
||||
child: Text(
|
||||
bottomLinkLabel ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.7),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFooter(ThemeData theme, ColorScheme colorScheme) {
|
||||
return Center(
|
||||
child: Text(
|
||||
'© Velocity · Fast, secure local payments',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.45),
|
||||
letterSpacing: 0.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A rounded card used to wrap onboarding forms. Provides the matching
|
||||
/// border, shadow, and inner padding used throughout the onboarding flow.
|
||||
class OnboardingCard extends StatelessWidget {
|
||||
const OnboardingCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding = const EdgeInsets.fromLTRB(24, 28, 24, 24),
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.06),
|
||||
width: 1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colorScheme.primary.withValues(alpha: 0.06),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.03),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hero section used at the top of onboarding cards: a tinted circular icon
|
||||
/// badge plus a title and optional subtitle.
|
||||
class OnboardingHero extends StatelessWidget {
|
||||
const OnboardingHero({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.accent,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Color? accent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final tint = accent ?? colorScheme.primary;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: tint.withValues(alpha: 0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(icon, color: tint, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: colorScheme.onSurface,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
subtitle!,
|
||||
textAlign: TextAlign.center,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.65),
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Modern, theme-aligned text field used by the onboarding forms.
|
||||
///
|
||||
/// Wraps a [TextFormField] and applies the consistent border, prefix icon,
|
||||
/// and focus styling that matches the rest of the Velocity design system.
|
||||
class OnboardingTextField extends StatelessWidget {
|
||||
const OnboardingTextField({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.hintText,
|
||||
this.labelText,
|
||||
this.prefixIcon,
|
||||
this.suffixIcon,
|
||||
this.keyboardType,
|
||||
this.textCapitalization = TextCapitalization.none,
|
||||
this.autocorrect = false,
|
||||
this.enableSuggestions = true,
|
||||
this.obscureText = false,
|
||||
this.validator,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
final TextEditingController controller;
|
||||
final String? hintText;
|
||||
final String? labelText;
|
||||
final IconData? prefixIcon;
|
||||
final Widget? suffixIcon;
|
||||
final TextInputType? keyboardType;
|
||||
final TextCapitalization textCapitalization;
|
||||
final bool autocorrect;
|
||||
final bool enableSuggestions;
|
||||
final bool obscureText;
|
||||
final String? Function(String?)? validator;
|
||||
final ValueChanged<String>? onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final borderRadius = BorderRadius.circular(14);
|
||||
|
||||
final enabledBorder = OutlineInputBorder(
|
||||
borderRadius: borderRadius,
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.12),
|
||||
width: 1,
|
||||
),
|
||||
);
|
||||
final focusedBorder = OutlineInputBorder(
|
||||
borderRadius: borderRadius,
|
||||
borderSide: BorderSide(color: colorScheme.primary, width: 1.6),
|
||||
);
|
||||
final errorBorder = OutlineInputBorder(
|
||||
borderRadius: borderRadius,
|
||||
borderSide: BorderSide(color: colorScheme.error, width: 1),
|
||||
);
|
||||
final focusedErrorBorder = OutlineInputBorder(
|
||||
borderRadius: borderRadius,
|
||||
borderSide: BorderSide(color: colorScheme.error, width: 1.6),
|
||||
);
|
||||
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
textCapitalization: textCapitalization,
|
||||
autocorrect: autocorrect,
|
||||
enableSuggestions: enableSuggestions,
|
||||
obscureText: obscureText,
|
||||
validator: validator,
|
||||
onChanged: onChanged,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
decoration: InputDecoration(
|
||||
hintText: hintText,
|
||||
labelText: labelText,
|
||||
prefixIcon: prefixIcon != null
|
||||
? Icon(
|
||||
prefixIcon,
|
||||
color: colorScheme.onSurface.withValues(alpha: 0.55),
|
||||
)
|
||||
: null,
|
||||
suffixIcon: suffixIcon,
|
||||
filled: true,
|
||||
fillColor: colorScheme.surface,
|
||||
enabledBorder: enabledBorder,
|
||||
focusedBorder: focusedBorder,
|
||||
errorBorder: errorBorder,
|
||||
focusedErrorBorder: focusedErrorBorder,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 16,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Primary CTA button used across the onboarding screens.
|
||||
class OnboardingPrimaryButton extends StatelessWidget {
|
||||
const OnboardingPrimaryButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.icon,
|
||||
this.onPressed,
|
||||
this.fullWidth = true,
|
||||
this.height = 52,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData? icon;
|
||||
final VoidCallback? onPressed;
|
||||
final bool fullWidth;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return SizedBox(
|
||||
width: fullWidth ? double.infinity : null,
|
||||
height: height,
|
||||
child: ElevatedButton(
|
||||
onPressed: onPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: colorScheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(label),
|
||||
if (icon != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Icon(icon, size: 20),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Secondary outlined button used across the onboarding screens.
|
||||
class OnboardingSecondaryButton extends StatelessWidget {
|
||||
const OnboardingSecondaryButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.icon,
|
||||
this.onPressed,
|
||||
this.fullWidth = true,
|
||||
this.height = 52,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final IconData? icon;
|
||||
final VoidCallback? onPressed;
|
||||
final bool fullWidth;
|
||||
final double height;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return SizedBox(
|
||||
width: fullWidth ? double.infinity : null,
|
||||
height: height,
|
||||
child: OutlinedButton(
|
||||
onPressed: onPressed,
|
||||
style: OutlinedButton.styleFrom(
|
||||
side: BorderSide(color: colorScheme.primary, width: 1.5),
|
||||
foregroundColor: colorScheme.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
Text(label),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:qpay/models/responsive_policy.dart';
|
||||
import 'package:qpay/screens/pay/pay_controller.dart';
|
||||
import 'package:qpay/screens/transaction_controller.dart';
|
||||
import 'package:qpay/widgets/app_snack_bar.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
|
||||
@@ -169,17 +170,11 @@ class _PayScreenState extends State<PayScreen> with TickerProviderStateMixin {
|
||||
|
||||
if (!apiResponse.isSuccess) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppSnackBar.showError(
|
||||
context,
|
||||
controller.model.errorMessage ??
|
||||
apiResponse.error ??
|
||||
"Transaction failed",
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
width: 600,
|
||||
backgroundColor: Colors.deepOrange,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else if (controller.model.status == "SUCCESS" && context.mounted) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:gif_view/gif_view.dart';
|
||||
import 'package:qpay/main.dart';
|
||||
import 'package:qpay/routes.dart';
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
77
lib/widgets/app_snack_bar.dart
Normal file
77
lib/widgets/app_snack_bar.dart
Normal file
@@ -0,0 +1,77 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Centralized helper for showing SnackBars across the app.
|
||||
///
|
||||
/// This guarantees that every SnackBar has a fixed width of 400 and a
|
||||
/// consistent floating behaviour, so the project-wide limit only has to
|
||||
/// be set in one place.
|
||||
class AppSnackBar {
|
||||
AppSnackBar._();
|
||||
|
||||
/// The default width applied to every floating SnackBar in the app.
|
||||
static const double width = 400;
|
||||
|
||||
/// Shows a SnackBar with the project-wide width of 400.
|
||||
///
|
||||
/// By default a [Text] widget with [message] is used as the content.
|
||||
/// Pass [customContent] to override the default content widget (e.g. for
|
||||
/// custom layouts that include icons, multiple lines, etc.).
|
||||
static void show(
|
||||
BuildContext context,
|
||||
String message, {
|
||||
Widget? customContent,
|
||||
Color? backgroundColor,
|
||||
Color? textColor,
|
||||
SnackBarBehavior behavior = SnackBarBehavior.floating,
|
||||
SnackBarAction? action,
|
||||
Duration duration = const Duration(seconds: 4),
|
||||
ShapeBorder? shape,
|
||||
EdgeInsetsGeometry? margin,
|
||||
EdgeInsetsGeometry? padding,
|
||||
}) {
|
||||
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||
if (messenger == null) return;
|
||||
|
||||
final Widget content = customContent ??
|
||||
(textColor != null
|
||||
? Text(message, style: TextStyle(color: textColor))
|
||||
: Text(message));
|
||||
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: content,
|
||||
backgroundColor: backgroundColor,
|
||||
behavior: behavior,
|
||||
action: action,
|
||||
duration: duration,
|
||||
shape: shape,
|
||||
margin: margin,
|
||||
padding: padding,
|
||||
width: width,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Convenience helper for error messages.
|
||||
static void showError(BuildContext context, String message) {
|
||||
show(
|
||||
context,
|
||||
message,
|
||||
backgroundColor: Colors.deepOrange,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convenience helper for success messages.
|
||||
static void showSuccess(BuildContext context, String message) {
|
||||
show(
|
||||
context,
|
||||
message,
|
||||
backgroundColor: Colors.green.shade700,
|
||||
);
|
||||
}
|
||||
|
||||
/// Convenience helper for informational messages.
|
||||
static void showInfo(BuildContext context, String message) {
|
||||
show(context, message);
|
||||
}
|
||||
}
|
||||
@@ -40,11 +40,11 @@ static void my_application_activate(GApplication* application) {
|
||||
if (use_header_bar) {
|
||||
GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new());
|
||||
gtk_widget_show(GTK_WIDGET(header_bar));
|
||||
gtk_header_bar_set_title(header_bar, "qpay");
|
||||
gtk_header_bar_set_title(header_bar, "Velocity Pay");
|
||||
gtk_header_bar_set_show_close_button(header_bar, TRUE);
|
||||
gtk_window_set_titlebar(window, GTK_WIDGET(header_bar));
|
||||
} else {
|
||||
gtk_window_set_title(window, "qpay");
|
||||
gtk_window_set_title(window, "Velocity Pay");
|
||||
}
|
||||
|
||||
gtk_window_set_default_size(window, 1280, 720);
|
||||
|
||||
@@ -6,7 +6,6 @@ import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import firebase_core
|
||||
import google_sign_in_ios
|
||||
import path_provider_foundation
|
||||
import share_plus
|
||||
import shared_preferences_foundation
|
||||
@@ -15,7 +14,6 @@ import webview_flutter_wkwebview
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||
FLTGoogleSignInPlugin.register(with: registry.registrar(forPlugin: "FLTGoogleSignInPlugin"))
|
||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
|
||||
68
pubspec.lock
68
pubspec.lock
@@ -237,26 +237,26 @@ packages:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: firebase_core
|
||||
sha256: "132e1c311bc41e7d387b575df0aacdf24efbf4930365eb61042be5bde3978f03"
|
||||
sha256: ec46a100a560d3bd5f97f2d89ba7492cb09b6dd0a4a28753d1258f360d6bd9f9
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.0"
|
||||
version: "4.10.0"
|
||||
firebase_core_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_platform_interface
|
||||
sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64
|
||||
sha256: "4a120366dbf7d5a8ee9438978530b664b855728fb8dcc3a201017660817e555b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.2"
|
||||
version: "7.0.1"
|
||||
firebase_core_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: firebase_core_web
|
||||
sha256: ecde2def458292404a4fcd3731ee4992fd631a0ec359d2d67c33baa8da5ec8ae
|
||||
sha256: "5ad1be848692ec148f2d6a8ad2a3838cb852ea5f3c9e6479a7afce479e1854f8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
version: "3.8.0"
|
||||
fixnum:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -360,54 +360,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.3.2"
|
||||
google_identity_services_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_identity_services_web
|
||||
sha256: "5d187c46dc59e02646e10fe82665fc3884a9b71bc1c90c2b8b749316d33ee454"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.3+1"
|
||||
google_sign_in:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_sign_in
|
||||
sha256: "521031b65853b4409b8213c0387d57edaad7e2a949ce6dea0d8b2afc9cb29763"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.2.0"
|
||||
google_sign_in_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_android
|
||||
sha256: "799165f4c0621ed233bccdded4c2e92739bc1fe73e970163b2f7493b301adad3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.2.1"
|
||||
google_sign_in_ios:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_ios
|
||||
sha256: d9d80f953a244a099a40df1ff6aadc10ee375e6a098bbd5d55be332ce26db18c
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.2.1"
|
||||
google_sign_in_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: google_sign_in_platform_interface
|
||||
sha256: "7f59208c42b415a3cca203571128d6f84f885fead2d5b53eb65a9e27f2965bb5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
google_sign_in_web:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_sign_in_web
|
||||
sha256: "2fc1f941e6443b2d6984f4056a727a3eaeab15d8ee99ba7125d79029be75a1da"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
graphs:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -728,6 +680,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.3"
|
||||
rename_app:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: rename_app
|
||||
sha256: "9df79b531a59124f3ec60316385809f79835eccf934a1ca7051f602a988acade"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.6"
|
||||
share_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -53,12 +53,10 @@ dependencies:
|
||||
share_plus: ^11.0.0
|
||||
flutter_contacts: ^1.1.9+2
|
||||
gif_view: ^0.4.0
|
||||
google_sign_in: ^7.1.1
|
||||
firebase_core: ^4.1.0
|
||||
url_launcher: ^6.3.2
|
||||
html: ^0.15.6
|
||||
web: ^1.1.1
|
||||
google_sign_in_web: ^1.1.0
|
||||
flutter_web_plugins:
|
||||
sdk: flutter
|
||||
decimal: ^3.2.4
|
||||
@@ -77,6 +75,7 @@ dev_dependencies:
|
||||
build_runner: ^2.5.4
|
||||
freezed: ^3.0.6
|
||||
json_serializable: ^6.9.5
|
||||
rename_app: ^1.6.6
|
||||
|
||||
# For information on the generic Dart part of this file, see the
|
||||
# following page: https://dart.dev/tools/pub/pubspec
|
||||
@@ -128,3 +127,6 @@ icons_launcher:
|
||||
enable: true
|
||||
web:
|
||||
enable: true
|
||||
|
||||
config_name:
|
||||
name: "Velocity Pay"
|
||||
|
||||
BIN
upload-keystore.jks
Normal file
BIN
upload-keystore.jks
Normal file
Binary file not shown.
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Velocity",
|
||||
"short_name": "Velocity",
|
||||
"name": "Velocity Pay",
|
||||
"short_name": "Velocity Pay",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
|
||||
@@ -27,7 +27,7 @@ int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev,
|
||||
FlutterWindow window(project);
|
||||
Win32Window::Point origin(10, 10);
|
||||
Win32Window::Size size(1280, 720);
|
||||
if (!window.Create(L"qpay", origin, size)) {
|
||||
if (!window.Create(L"Velocity Pay", origin, size)) {
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
window.SetQuitOnClose(true);
|
||||
|
||||
Reference in New Issue
Block a user