completed group batch feature

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

View File

@@ -1,17 +1,12 @@
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
View File

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

View File

@@ -1,38 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_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(),
);
}
}

View File

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

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

View File

@@ -4,6 +4,7 @@ import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:provider/provider.dart';
import 'package: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());
});
}

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/screens/groups/controllers/batch_controller.dart';
import 'package:qpay/screens/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);
},
);
}
}

View File

@@ -5,6 +5,7 @@ import 'package:qpay/screens/groups/models/group_batch_model.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/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.');
}
_showError(updateResult.error ?? 'Failed to update processor.');
return;
}
@@ -177,15 +176,281 @@ 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 {
final batch = _batch;
if (batch == null) return;
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;
@@ -538,7 +833,14 @@ class _BatchDetailScreenState extends State<BatchDetailScreen>
padding: const EdgeInsets.symmetric(vertical: 20, horizontal: 20),
child: Column(
children: [
_buildStatusBadge(status),
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,

View File

@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
import 'package:qpay/screens/groups/models/recipient_group_model.dart';
import 'package:qpay/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(
'Please add at least one recipient in name,account format.',
),
),
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.',
);
}
}

View File

@@ -5,7 +5,9 @@ import 'package:qpay/screens/groups/models/recipient_group_model.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/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,140 +87,631 @@ 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,
expand: false,
builder: (context, scrollController) {
return Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
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.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 Spacer(),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
const SizedBox(height: 2),
Text(
'$total ${total == 1 ? "member" : "members"} in this group',
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
),
],
),
const SizedBox(height: 4),
Text(
'${groupController.model.members.length} member(s)',
style: TextStyle(fontSize: 14, color: Colors.grey.shade600),
),
IconButton(
icon: Icon(Icons.close, color: Colors.grey.shade700),
tooltip: 'Close',
onPressed: () => Navigator.of(context).pop(),
),
],
),
if (total > 0) ...[
const SizedBox(height: 14),
TextFormField(
controller: _memberSearchController,
onChanged: (v) {
setSheetState(() => _memberQuery = v);
},
decoration: InputDecoration(
hintText: 'Search members...',
hintStyle: TextStyle(
color: Colors.grey.shade500,
fontSize: 14,
),
const SizedBox(height: 12),
const Divider(),
Expanded(
child: ListenableBuilder(
listenable: groupController,
builder: (context, _) {
if (groupController.model.isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (groupController.model.members.isEmpty) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.person_outline,
size: 48,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
const Text(
'No members found.',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
],
),
),
);
}
return ListView.separated(
controller: scrollController,
itemCount: groupController.model.members.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final member = groupController.model.members[index];
return ListTile(
contentPadding: const EdgeInsets.symmetric(
vertical: 2,
horizontal: 4,
),
leading: CircleAvatar(
backgroundColor: Theme.of(
context,
).colorScheme.primary.withAlpha(30),
child: Text(
member.recipient.name?.isNotEmpty == true
? member.recipient.name![0].toUpperCase()
: '?',
style: TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
title: Text(
member.recipient.name ?? '',
style: const TextStyle(
fontWeight: FontWeight.w500,
),
),
subtitle: Text(member.recipient.account ?? ''),
trailing:
member.recipient.latestProviderLabel != null &&
member
.recipient
.latestProviderLabel!
.isNotEmpty
? Chip(
label: Text(
member.recipient.latestProviderLabel!,
style: const TextStyle(fontSize: 11),
),
visualDensity: VisualDensity.compact,
)
: null,
);
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,
),
),
],
),
),
);
},
],
],
),
);
}
List<RecipientGroupMember> _filteredMembers(
List<RecipientGroupMember> members,
) {
if (_memberQuery.trim().isEmpty) return members;
final q = _memberQuery.toLowerCase().trim();
return members.where((m) {
final r = m.recipient;
return (r.name?.toLowerCase().contains(q) ?? false) ||
(r.account?.toLowerCase().contains(q) ?? false) ||
(r.email?.toLowerCase().contains(q) ?? false) ||
(r.phoneNumber?.toLowerCase().contains(q) ?? false) ||
(r.latestProviderLabel?.toLowerCase().contains(q) ?? false);
}).toList();
}
Widget _buildAnimatedMemberCard(int index, RecipientGroupMember member) {
final clampedIndex = index.clamp(0, 5);
final animation = Tween<Offset>(
begin: const Offset(0, 0.06),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _membersAnimController,
curve: Interval(
0.05 * clampedIndex,
1.0,
curve: Curves.easeOutCubic,
),
),
);
return FadeTransition(
opacity: _membersAnimController,
child: SlideTransition(
position: animation,
child: _buildMemberCard(member),
),
);
}
Widget _buildMemberCard(RecipientGroupMember member) {
final recipient = member.recipient;
final name = recipient.name?.isNotEmpty == true
? recipient.name!
: 'Unnamed recipient';
final initials = _initialsFor(name);
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: () {
Navigator.of(context).pop();
},
child: Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: Colors.grey.shade200, width: 1),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 14,
horizontal: 14,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildMemberAvatar(initials),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 6),
_buildMemberSubtitleRow(recipient, member.account),
if (recipient.phoneNumber != null &&
recipient.phoneNumber!.isNotEmpty) ...[
const SizedBox(height: 6),
_buildContactLine(
Icons.phone_outlined,
recipient.phoneNumber!,
),
],
if (recipient.email != null &&
recipient.email!.isNotEmpty) ...[
const SizedBox(height: 4),
_buildContactLine(
Icons.email_outlined,
recipient.email!,
),
],
],
),
),
_buildMemberPopupMenu(member),
],
),
),
),
),
);
}
Widget _buildMemberAvatar(String initials) {
return Container(
width: 48,
height: 48,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Theme.of(context).colorScheme.primary,
Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.7),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.25),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
alignment: Alignment.center,
child: Text(
initials,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Colors.white,
letterSpacing: 0.5,
),
),
);
}
Widget _buildMemberSubtitleRow(
Recipient recipient,
String? memberAccount,
) {
final account = memberAccount?.isNotEmpty == true
? memberAccount
: recipient.account;
final hasAccount = account != null && account.isNotEmpty;
final hasProvider = recipient.latestProviderLabel != null &&
recipient.latestProviderLabel!.isNotEmpty;
if (!hasAccount && !hasProvider) {
return const SizedBox.shrink();
}
return Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 6,
runSpacing: 4,
children: [
if (hasProvider)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(6),
),
child: Text(
recipient.latestProviderLabel!,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary,
),
),
),
if (hasAccount)
Text(
account!,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade700,
fontWeight: FontWeight.w500,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
);
}
Widget _buildContactLine(IconData icon, String text) {
return Row(
children: [
Icon(icon, size: 13, color: Colors.grey.shade500),
const SizedBox(width: 4),
Flexible(
child: Text(
text,
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
Widget _buildMemberPopupMenu(RecipientGroupMember member) {
return PopupMenuButton<String>(
icon: Icon(
Icons.more_vert,
color: Colors.grey.shade500,
size: 20,
),
padding: EdgeInsets.zero,
splashRadius: 20,
tooltip: 'More options',
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onSelected: (value) async {
if (value == 'remove') {
await _confirmRemoveMember(member);
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'remove',
child: Row(
children: [
Icon(Icons.person_remove_outlined,
size: 18, color: Colors.red),
SizedBox(width: 10),
Text(
'Remove from group',
style: TextStyle(
color: Colors.red,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
);
}
Future<void> _confirmRemoveMember(RecipientGroupMember member) async {
final name = member.recipient.name ?? 'this member';
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
title: const Text('Remove member'),
content: Text('Are you sure you want to remove $name from this group?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Remove'),
),
],
),
);
if (confirmed == true && mounted) {
await groupController.removeMember(
groupId: widget.group.id ?? '',
recipientId: member.id ?? '',
userId: widget.group.userId ?? '',
);
}
}
Widget _buildMembersSkeletonList(ScrollController scrollController) {
return ListView.separated(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
itemCount: 6,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, index) {
return Skeletonizer(
enabled: true,
child: _buildMemberCard(
RecipientGroupMember(
recipientGroupId: '',
recipient: Recipient(
name: 'Loading Name Here',
account: '0000000000',
phoneNumber: '+000 000 0000',
email: 'loading@example.com',
latestProviderLabel: 'PROVIDER',
),
),
),
);
},
);
}
Widget _buildMembersEmptyState() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withValues(alpha: 0.08),
shape: BoxShape.circle,
),
child: Icon(
Icons.people_alt_outlined,
size: 44,
color: Theme.of(context).colorScheme.primary,
),
),
const SizedBox(height: 20),
const Text(
'No members yet',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.black87,
),
),
const SizedBox(height: 8),
Text(
'Members added to this group will appear\nhere once they are available.',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade600,
height: 1.4,
),
),
],
),
),
);
}
Widget _buildNoSearchResultsState() {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.search_off_outlined,
size: 56,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
const Text(
'No matching members',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 6),
Text(
'No results for "$_memberQuery"',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: Colors.grey.shade600,
),
),
],
),
),
);
}
String _initialsFor(String name) {
final cleaned = name.trim();
if (cleaned.isEmpty) return '?';
final parts = cleaned.split(RegExp(r'\s+'));
if (parts.length == 1) {
return parts.first.characters.first.toUpperCase();
}
return (parts.first.characters.first + parts.last.characters.first)
.toUpperCase();
}
void _onDeleteSelected() async {
final userId = widget.group.userId ?? '';
final count = batchController.model.selectedBatchIds.length;
@@ -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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import 'package:dio/dio.dart';
import 'package: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 =

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import 'package:provider/provider.dart';
import 'package:qpay/models/responsive_policy.dart';
import 'package:qpay/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(
controller.model.errorMessage ??
apiResponse.error ??
"Transaction failed",
),
behavior: SnackBarBehavior.floating,
width: 600,
backgroundColor: Colors.deepOrange,
),
AppSnackBar.showError(
context,
controller.model.errorMessage ??
apiResponse.error ??
"Transaction failed",
);
}
} else if (controller.model.status == "SUCCESS" && context.mounted) {

View File

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

View File

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