diff --git a/apps/mobile/analysis_options.yaml b/apps/mobile/analysis_options.yaml index cec4e925..2b4df59c 100644 --- a/apps/mobile/analysis_options.yaml +++ b/apps/mobile/analysis_options.yaml @@ -6,6 +6,7 @@ analyzer: - "**/*.g.dart" - "**/*.freezed.dart" - "**/*.config.dart" + - "apps/mobile/prototypes/**" errors: # Set the severity of the always_specify_types rule to warning as requested. always_specify_types: warning diff --git a/apps/mobile/apps/client/lib/main.dart b/apps/mobile/apps/client/lib/main.dart index 47d6a076..a0e67c19 100644 --- a/apps/mobile/apps/client/lib/main.dart +++ b/apps/mobile/apps/client/lib/main.dart @@ -14,8 +14,10 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_modular/flutter_modular.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; import 'firebase_options.dart'; +import 'src/widgets/session_listener.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -28,8 +30,18 @@ void main() async { logEvents: true, logStateChanges: false, // Set to true for verbose debugging ); + + // Initialize session listener for Firebase Auth state changes + DataConnectService.instance.initializeAuthListener( + allowedRoles: ['CLIENT', 'BUSINESS', 'BOTH'], // Only allow users with CLIENT, BUSINESS, or BOTH roles + ); - runApp(ModularApp(module: AppModule(), child: const AppWidget())); + runApp( + ModularApp( + module: AppModule(), + child: const SessionListener(child: AppWidget()), + ), + ); } /// The main application module for the Client app. diff --git a/apps/mobile/apps/client/lib/src/widgets/session_listener.dart b/apps/mobile/apps/client/lib/src/widgets/session_listener.dart new file mode 100644 index 00000000..f481633b --- /dev/null +++ b/apps/mobile/apps/client/lib/src/widgets/session_listener.dart @@ -0,0 +1,163 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; + +/// A widget that listens to session state changes and handles global reactions. +/// +/// This widget wraps the entire app and provides centralized session management, +/// such as logging out when the session expires or handling session errors. +class SessionListener extends StatefulWidget { + /// Creates a [SessionListener]. + const SessionListener({required this.child, super.key}); + + /// The child widget to wrap. + final Widget child; + + @override + State createState() => _SessionListenerState(); +} + +class _SessionListenerState extends State { + late StreamSubscription _sessionSubscription; + bool _sessionExpiredDialogShown = false; + bool _isInitialState = true; + + @override + void initState() { + super.initState(); + _setupSessionListener(); + } + + void _setupSessionListener() { + _sessionSubscription = DataConnectService.instance.onSessionStateChanged + .listen((SessionState state) { + _handleSessionChange(state); + }); + + debugPrint('[SessionListener] Initialized session listener'); + } + + void _handleSessionChange(SessionState state) { + if (!mounted) return; + + switch (state.type) { + case SessionStateType.unauthenticated: + debugPrint( + '[SessionListener] Unauthenticated: Session expired or user logged out', + ); + // On initial state (cold start), just proceed to login without dialog + // Only show dialog if user was previously authenticated (session expired) + if (_isInitialState) { + _isInitialState = false; + Modular.to.toClientGetStartedPage(); + } else if (!_sessionExpiredDialogShown) { + _sessionExpiredDialogShown = true; + _showSessionExpiredDialog(); + } + break; + + case SessionStateType.authenticated: + // Session restored or user authenticated + _isInitialState = false; + _sessionExpiredDialogShown = false; + debugPrint('[SessionListener] Authenticated: ${state.userId}'); + + // Navigate to the main app + Modular.to.toClientHome(); + break; + + case SessionStateType.error: + // Show error notification with option to retry or logout + // Only show if not initial state (avoid showing on cold start) + if (!_isInitialState) { + debugPrint('[SessionListener] Session error: ${state.errorMessage}'); + _showSessionErrorDialog(state.errorMessage ?? 'Session error occurred'); + } else { + _isInitialState = false; + Modular.to.toClientGetStartedPage(); + } + break; + + case SessionStateType.loading: + // Session is loading, optionally show a loading indicator + debugPrint('[SessionListener] Session loading...'); + break; + } + } + + /// Shows a dialog when the session expires. + void _showSessionExpiredDialog() { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Session Expired'), + content: const Text( + 'Your session has expired. Please log in again to continue.', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _proceedToLogin(); + }, + child: const Text('Log In'), + ), + ], + ); + }, + ); + } + + /// Shows a dialog when a session error occurs, with retry option. + void _showSessionErrorDialog(String errorMessage) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Session Error'), + content: Text(errorMessage), + actions: [ + TextButton( + onPressed: () { + // User can retry by dismissing and continuing + Modular.to.pop(); + }, + child: const Text('Continue'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _proceedToLogin(); + }, + child: const Text('Log Out'), + ), + ], + ); + }, + ); + } + + /// Navigate to login screen and clear navigation stack. + void _proceedToLogin() { + // Clear service caches on sign-out + DataConnectService.instance.handleSignOut(); + + // Navigate to authentication + Modular.to.toClientGetStartedPage(); + } + + @override + void dispose() { + _sessionSubscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/apps/mobile/apps/client/pubspec.yaml b/apps/mobile/apps/client/pubspec.yaml index 101a2b77..e947f7b5 100644 --- a/apps/mobile/apps/client/pubspec.yaml +++ b/apps/mobile/apps/client/pubspec.yaml @@ -41,6 +41,7 @@ dependencies: flutter_localizations: sdk: flutter firebase_core: ^4.4.0 + krow_data_connect: ^0.0.1 dev_dependencies: flutter_test: diff --git a/apps/mobile/apps/staff/lib/main.dart b/apps/mobile/apps/staff/lib/main.dart index eba7af00..1858e1bd 100644 --- a/apps/mobile/apps/staff/lib/main.dart +++ b/apps/mobile/apps/staff/lib/main.dart @@ -5,25 +5,36 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krowwithus_staff/firebase_options.dart'; import 'package:staff_authentication/staff_authentication.dart' as staff_authentication; import 'package:staff_main/staff_main.dart' as staff_main; import 'package:krow_core/core.dart'; +import 'src/widgets/session_listener.dart'; + void main() async { WidgetsFlutterBinding.ensureInitialized(); - await Firebase.initializeApp( - options: DefaultFirebaseOptions.currentPlatform, - ); - + await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); + // Register global BLoC observer for centralized error logging Bloc.observer = CoreBlocObserver( logEvents: true, logStateChanges: false, // Set to true for verbose debugging ); - - runApp(ModularApp(module: AppModule(), child: const AppWidget())); + + // Initialize session listener for Firebase Auth state changes + DataConnectService.instance.initializeAuthListener( + allowedRoles: ['STAFF', 'BOTH'], // Only allow users with STAFF or BOTH roles + ); + + runApp( + ModularApp( + module: AppModule(), + child: const SessionListener(child: AppWidget()), + ), + ); } /// The main application module. @@ -34,7 +45,10 @@ class AppModule extends Module { @override void routes(RouteManager r) { // Set the initial route to the authentication module - r.module(StaffPaths.root, module: staff_authentication.StaffAuthenticationModule()); + r.module( + StaffPaths.root, + module: staff_authentication.StaffAuthenticationModule(), + ); r.module(StaffPaths.main, module: staff_main.StaffMainModule()); } diff --git a/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart new file mode 100644 index 00000000..258bd901 --- /dev/null +++ b/apps/mobile/apps/staff/lib/src/widgets/session_listener.dart @@ -0,0 +1,163 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart'; + +/// A widget that listens to session state changes and handles global reactions. +/// +/// This widget wraps the entire app and provides centralized session management, +/// such as logging out when the session expires or handling session errors. +class SessionListener extends StatefulWidget { + /// Creates a [SessionListener]. + const SessionListener({required this.child, super.key}); + + /// The child widget to wrap. + final Widget child; + + @override + State createState() => _SessionListenerState(); +} + +class _SessionListenerState extends State { + late StreamSubscription _sessionSubscription; + bool _sessionExpiredDialogShown = false; + bool _isInitialState = true; + + @override + void initState() { + super.initState(); + _setupSessionListener(); + } + + void _setupSessionListener() { + _sessionSubscription = DataConnectService.instance.onSessionStateChanged + .listen((SessionState state) { + _handleSessionChange(state); + }); + + debugPrint('[SessionListener] Initialized session listener'); + } + + void _handleSessionChange(SessionState state) { + if (!mounted) return; + + switch (state.type) { + case SessionStateType.unauthenticated: + debugPrint( + '[SessionListener] Unauthenticated: Session expired or user logged out', + ); + // On initial state (cold start), just proceed to login without dialog + // Only show dialog if user was previously authenticated (session expired) + if (_isInitialState) { + _isInitialState = false; + Modular.to.toGetStartedPage(); + } else if (!_sessionExpiredDialogShown) { + _sessionExpiredDialogShown = true; + _showSessionExpiredDialog(); + } + break; + + case SessionStateType.authenticated: + // Session restored or user authenticated + _isInitialState = false; + _sessionExpiredDialogShown = false; + debugPrint('[SessionListener] Authenticated: ${state.userId}'); + + // Navigate to the main app + Modular.to.toStaffHome(); + break; + + case SessionStateType.error: + // Show error notification with option to retry or logout + // Only show if not initial state (avoid showing on cold start) + if (!_isInitialState) { + debugPrint('[SessionListener] Session error: ${state.errorMessage}'); + _showSessionErrorDialog(state.errorMessage ?? 'Session error occurred'); + } else { + _isInitialState = false; + Modular.to.toGetStartedPage(); + } + break; + + case SessionStateType.loading: + // Session is loading, optionally show a loading indicator + debugPrint('[SessionListener] Session loading...'); + break; + } + } + + /// Shows a dialog when the session expires. + void _showSessionExpiredDialog() { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Session Expired'), + content: const Text( + 'Your session has expired. Please log in again to continue.', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _proceedToLogin(); + }, + child: const Text('Log In'), + ), + ], + ); + }, + ); + } + + /// Shows a dialog when a session error occurs, with retry option. + void _showSessionErrorDialog(String errorMessage) { + showDialog( + context: context, + barrierDismissible: false, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Session Error'), + content: Text(errorMessage), + actions: [ + TextButton( + onPressed: () { + // User can retry by dismissing and continuing + Modular.to.pop(); + }, + child: const Text('Continue'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + _proceedToLogin(); + }, + child: const Text('Log Out'), + ), + ], + ); + }, + ); + } + + /// Navigate to login screen and clear navigation stack. + void _proceedToLogin() { + // Clear service caches on sign-out + DataConnectService.instance.handleSignOut(); + + // Navigate to authentication + Modular.to.toGetStartedPage(); + } + + @override + void dispose() { + _sessionSubscription.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/apps/mobile/apps/staff/pubspec.yaml b/apps/mobile/apps/staff/pubspec.yaml index 8bc77687..d3b270ef 100644 --- a/apps/mobile/apps/staff/pubspec.yaml +++ b/apps/mobile/apps/staff/pubspec.yaml @@ -28,6 +28,8 @@ dependencies: path: ../../packages/features/staff/staff_main krow_core: path: ../../packages/core + krow_data_connect: + path: ../../packages/data_connect cupertino_icons: ^1.0.8 flutter_modular: ^6.3.0 firebase_core: ^4.4.0 diff --git a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart index d51abda4..4c7bcd34 100644 --- a/apps/mobile/packages/core/lib/src/routing/client/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/client/navigator.dart @@ -21,20 +21,27 @@ import 'route_paths.dart'; /// /// See also: /// * [ClientPaths] for route path constants -/// * [StaffNavigator] for Staff app navigation +/// * [ClientNavigator] for Client app navigation extension ClientNavigator on IModularNavigator { // ========================================================================== // AUTHENTICATION FLOWS // ========================================================================== /// Navigate to the root authentication screen. - /// + /// /// This effectively logs out the user by navigating to root. /// Used when signing out or session expires. void toClientRoot() { navigate(ClientPaths.root); } + /// Navigates to the get started page. + /// + /// This is the landing page for unauthenticated users, offering login/signup options. + void toClientGetStartedPage() { + navigate(ClientPaths.getStarted); + } + /// Navigates to the client sign-in page. /// /// This page allows existing clients to log in using email/password diff --git a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart index 2e22a0ce..1269484c 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart @@ -32,10 +32,17 @@ extension StaffNavigator on IModularNavigator { /// /// This effectively logs out the user by navigating to root. /// Used when signing out or session expires. - void toGetStarted() { + void toInitialPage() { navigate(StaffPaths.root); } + /// Navigates to the get started page. + /// + /// This is the landing page for unauthenticated users, offering login/signup options. + void toGetStartedPage() { + navigate(StaffPaths.getStarted); + } + /// Navigates to the phone verification page. /// /// Used for both login and signup flows to verify phone numbers via OTP. diff --git a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart index d512a29c..7afa4c97 100644 --- a/apps/mobile/packages/data_connect/lib/krow_data_connect.dart +++ b/apps/mobile/packages/data_connect/lib/krow_data_connect.dart @@ -3,7 +3,6 @@ /// This package provides mock implementations of domain repository interfaces /// for development and testing purposes. /// -/// TODO: These mocks currently do not implement any specific interfaces. /// They will implement interfaces defined in feature packages once those are created. library; @@ -14,6 +13,7 @@ export 'src/session/client_session_store.dart'; // Export the generated Data Connect SDK export 'src/dataconnect_generated/generated.dart'; export 'src/services/data_connect_service.dart'; +export 'src/services/mixins/session_handler_mixin.dart'; export 'src/session/staff_session_store.dart'; -export 'src/mixins/data_error_handler.dart'; +export 'src/services/mixins/data_error_handler.dart'; diff --git a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart index bad4b174..19799467 100644 --- a/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart +++ b/apps/mobile/packages/data_connect/lib/src/services/data_connect_service.dart @@ -2,16 +2,18 @@ import 'dart:async'; import 'package:firebase_auth/firebase_auth.dart' as firebase_auth; import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; +import 'package:flutter/material.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import '../../krow_data_connect.dart' as dc; -import '../mixins/data_error_handler.dart'; +import 'mixins/data_error_handler.dart'; +import 'mixins/session_handler_mixin.dart'; /// A centralized service for interacting with Firebase Data Connect. /// /// This service provides common utilities and context management for all repositories. -class DataConnectService with DataErrorHandler { +class DataConnectService with DataErrorHandler, SessionHandlerMixin { DataConnectService._(); /// The singleton instance of the [DataConnectService]. @@ -50,8 +52,11 @@ class DataConnectService with DataErrorHandler { } try { - final fdc.QueryResult - response = await executeProtected( + final fdc.QueryResult< + dc.GetStaffByUserIdData, + dc.GetStaffByUserIdVariables + > + response = await executeProtected( () => connector.getStaffByUserId(userId: user.uid).execute(), ); @@ -78,7 +83,7 @@ class DataConnectService with DataErrorHandler { // 2. Check Cache if (_cachedBusinessId != null) return _cachedBusinessId!; - // 3. Check Auth Status + // 3. Fetch from Data Connect using Firebase UID final firebase_auth.User? user = _auth.currentUser; if (user == null) { throw const NotAuthenticatedException( @@ -86,8 +91,24 @@ class DataConnectService with DataErrorHandler { ); } - // 4. Fallback (should ideally not happen if DB is seeded and session is initialized) - // Ideally we'd have a getBusinessByUserId query here. + try { + final fdc.QueryResult< + dc.GetBusinessesByUserIdData, + dc.GetBusinessesByUserIdVariables + > + response = await executeProtected( + () => connector.getBusinessesByUserId(userId: user.uid).execute(), + ); + + if (response.data.businesses.isNotEmpty) { + _cachedBusinessId = response.data.businesses.first.id; + return _cachedBusinessId!; + } + } catch (e) { + throw Exception('Failed to fetch business ID from Data Connect: $e'); + } + + // 4. Fallback (should ideally not happen if DB is seeded) return user.uid; } @@ -130,13 +151,18 @@ class DataConnectService with DataErrorHandler { Future run( Future Function() action, { bool requiresAuthentication = true, - }) { + }) async { if (requiresAuthentication && auth.currentUser == null) { throw const NotAuthenticatedException( technicalMessage: 'User must be authenticated to perform this action', ); } - return executeProtected(action); + + return executeProtected(() async { + // Ensure session token is valid and refresh if needed + await ensureSessionValid(); + return action(); + }); } /// Clears the internal cache (e.g., on logout). @@ -144,4 +170,28 @@ class DataConnectService with DataErrorHandler { _cachedStaffId = null; _cachedBusinessId = null; } + + /// Handle session sign-out by clearing caches. + void handleSignOut() { + clearCache(); + } + + @override + Future fetchUserRole(String userId) async { + try { + final fdc.QueryResult + response = await executeProtected( + () => connector.getUserById(id: userId).execute(), + ); + return response.data.user?.userRole; + } catch (e) { + debugPrint('Failed to fetch user role: $e'); + return null; + } + } + + /// Dispose all resources (call on app shutdown). + Future dispose() async { + await disposeSessionHandler(); + } } diff --git a/apps/mobile/packages/data_connect/lib/src/mixins/data_error_handler.dart b/apps/mobile/packages/data_connect/lib/src/services/mixins/data_error_handler.dart similarity index 76% rename from apps/mobile/packages/data_connect/lib/src/mixins/data_error_handler.dart rename to apps/mobile/packages/data_connect/lib/src/services/mixins/data_error_handler.dart index aec89758..49a5cbea 100644 --- a/apps/mobile/packages/data_connect/lib/src/mixins/data_error_handler.dart +++ b/apps/mobile/packages/data_connect/lib/src/services/mixins/data_error_handler.dart @@ -20,8 +20,12 @@ mixin DataErrorHandler { try { return await action().timeout(timeout); } on TimeoutException { + debugPrint( + 'DataErrorHandler: Request timed out after ${timeout.inSeconds}s', + ); throw ServiceUnavailableException( - technicalMessage: 'Request timed out after ${timeout.inSeconds}s'); + technicalMessage: 'Request timed out after ${timeout.inSeconds}s', + ); } on SocketException catch (e) { throw NetworkException(technicalMessage: 'SocketException: ${e.message}'); } on FirebaseException catch (e) { @@ -32,16 +36,26 @@ mixin DataErrorHandler { msg.contains('offline') || msg.contains('network') || msg.contains('connection failed')) { + debugPrint( + 'DataErrorHandler: Firebase network error: ${e.code} - ${e.message}', + ); throw NetworkException( - technicalMessage: 'Firebase ${e.code}: ${e.message}'); + technicalMessage: 'Firebase ${e.code}: ${e.message}', + ); } if (code == 'deadline-exceeded') { + debugPrint( + 'DataErrorHandler: Firebase timeout error: ${e.code} - ${e.message}', + ); throw ServiceUnavailableException( - technicalMessage: 'Firebase ${e.code}: ${e.message}'); + technicalMessage: 'Firebase ${e.code}: ${e.message}', + ); } + debugPrint('DataErrorHandler: Firebase error: ${e.code} - ${e.message}'); // Fallback for other Firebase errors throw ServerException( - technicalMessage: 'Firebase ${e.code}: ${e.message}'); + technicalMessage: 'Firebase ${e.code}: ${e.message}', + ); } catch (e) { final String errorStr = e.toString().toLowerCase(); if (errorStr.contains('socketexception') || @@ -56,15 +70,16 @@ mixin DataErrorHandler { errorStr.contains('grpc error') || errorStr.contains('terminated') || errorStr.contains('connectexception')) { + debugPrint('DataErrorHandler: Network-related error: $e'); throw NetworkException(technicalMessage: e.toString()); } - + // If it's already an AppException, rethrow it if (e is AppException) rethrow; // Debugging: Log unexpected errors debugPrint('DataErrorHandler: Unhandled exception caught: $e'); - + throw UnknownException(technicalMessage: e.toString()); } } diff --git a/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart b/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart new file mode 100644 index 00000000..393f4b8a --- /dev/null +++ b/apps/mobile/packages/data_connect/lib/src/services/mixins/session_handler_mixin.dart @@ -0,0 +1,255 @@ +import 'dart:async'; + +import 'package:firebase_auth/firebase_auth.dart' as firebase_auth; +import 'package:flutter/cupertino.dart'; + +/// Enum representing the current session state. +enum SessionStateType { loading, authenticated, unauthenticated, error } + +/// Data class for session state. +class SessionState { + /// Creates a [SessionState]. + SessionState({required this.type, this.userId, this.errorMessage}); + + /// Creates a loading state. + factory SessionState.loading() => + SessionState(type: SessionStateType.loading); + + /// Creates an authenticated state. + factory SessionState.authenticated({required String userId}) => + SessionState(type: SessionStateType.authenticated, userId: userId); + + /// Creates an unauthenticated state. + factory SessionState.unauthenticated() => + SessionState(type: SessionStateType.unauthenticated); + + /// Creates an error state. + factory SessionState.error(String message) => + SessionState(type: SessionStateType.error, errorMessage: message); + + /// The type of session state. + final SessionStateType type; + + /// The current user ID (if authenticated). + final String? userId; + + /// Error message (if error occurred). + final String? errorMessage; + + @override + String toString() => + 'SessionState(type: $type, userId: $userId, error: $errorMessage)'; +} + +/// Mixin for handling Firebase Auth session management, token refresh, and state emissions. +mixin SessionHandlerMixin { + /// Stream controller for session state changes. + final StreamController _sessionStateController = + StreamController.broadcast(); + + /// Last emitted session state (for late subscribers). + SessionState? _lastSessionState; + + /// Public stream for listening to session state changes. + /// Late subscribers will immediately receive the last emitted state. + Stream get onSessionStateChanged { + // Create a custom stream that emits the last state before forwarding new events + return _createStreamWithLastState(); + } + + /// Creates a stream that emits the last state before subscribing to new events. + Stream _createStreamWithLastState() async* { + // If we have a last state, emit it immediately to late subscribers + if (_lastSessionState != null) { + yield _lastSessionState!; + } + // Then forward all subsequent events + yield* _sessionStateController.stream; + } + + /// Last token refresh timestamp to avoid excessive checks. + DateTime? _lastTokenRefreshTime; + + /// Subscription to auth state changes. + StreamSubscription? _authStateSubscription; + + /// Minimum interval between token refresh checks. + static const Duration _minRefreshCheckInterval = Duration(seconds: 2); + + /// Time before token expiry to trigger a refresh. + static const Duration _refreshThreshold = Duration(minutes: 5); + + /// Firebase Auth instance (to be provided by implementing class). + firebase_auth.FirebaseAuth get auth; + + /// List of allowed roles for this app (to be set during initialization). + List _allowedRoles = []; + + /// Initialize the auth state listener (call once on app startup). + void initializeAuthListener({List allowedRoles = const []}) { + _allowedRoles = allowedRoles; + + // Cancel any existing subscription first + _authStateSubscription?.cancel(); + + // Listen to Firebase auth state changes + _authStateSubscription = auth.authStateChanges().listen( + (firebase_auth.User? user) async { + if (user == null) { + _handleSignOut(); + } else { + await _handleSignIn(user); + } + }, + onError: (Object error) { + _emitSessionState(SessionState.error(error.toString())); + }, + ); + } + + /// Validates if user has one of the allowed roles. + /// Returns true if user role is in allowed roles, false otherwise. + Future validateUserRole( + String userId, + List allowedRoles, + ) async { + try { + final String? userRole = await fetchUserRole(userId); + return userRole != null && allowedRoles.contains(userRole); + } catch (e) { + debugPrint('Failed to validate user role: $e'); + return false; + } + } + + /// Fetches user role from Data Connect. + /// To be implemented by concrete class. + Future fetchUserRole(String userId); + + /// Ensures the Firebase auth token is valid and refreshes if needed. + /// Retries up to 3 times with exponential backoff before emitting error. + Future ensureSessionValid() async { + final firebase_auth.User? user = auth.currentUser; + + // No user = not authenticated, skip check + if (user == null) return; + + // Optimization: Skip if we just checked within the last 2 seconds + final DateTime now = DateTime.now(); + if (_lastTokenRefreshTime != null) { + final Duration timeSinceLastCheck = now.difference( + _lastTokenRefreshTime!, + ); + if (timeSinceLastCheck < _minRefreshCheckInterval) { + return; // Skip redundant check + } + } + + const int maxRetries = 3; + int retryCount = 0; + + while (retryCount < maxRetries) { + try { + // Get token result (doesn't fetch from network unless needed) + final firebase_auth.IdTokenResult idToken = await user + .getIdTokenResult(); + + // Extract expiration time + final DateTime? expiryTime = idToken.expirationTime; + + if (expiryTime == null) { + return; // Token info unavailable, proceed anyway + } + + // Calculate time until expiry + final Duration timeUntilExpiry = expiryTime.difference(now); + + // If token expires within 5 minutes, refresh it + if (timeUntilExpiry <= _refreshThreshold) { + await user.getIdTokenResult(); + } + + // Update last refresh check timestamp + _lastTokenRefreshTime = now; + return; // Success, exit retry loop + } catch (e) { + retryCount++; + debugPrint( + 'Token validation error (attempt $retryCount/$maxRetries): $e', + ); + + // If we've exhausted retries, emit error + if (retryCount >= maxRetries) { + _emitSessionState( + SessionState.error( + 'Token validation failed after $maxRetries attempts: $e', + ), + ); + return; + } + + // Exponential backoff: 1s, 2s, 4s + final Duration backoffDuration = Duration( + seconds: 1 << (retryCount - 1), // 2^(retryCount-1) + ); + debugPrint( + 'Retrying token validation in ${backoffDuration.inSeconds}s', + ); + await Future.delayed(backoffDuration); + } + } + } + + /// Handle user sign-in event. + Future _handleSignIn(firebase_auth.User user) async { + try { + _emitSessionState(SessionState.loading()); + + // Validate role if allowed roles are specified + if (_allowedRoles.isNotEmpty) { + final bool isAuthorized = await validateUserRole( + user.uid, + _allowedRoles, + ); + if (!isAuthorized) { + await auth.signOut(); + _emitSessionState(SessionState.unauthenticated()); + return; + } + } + + // Get fresh token to validate session + final firebase_auth.IdTokenResult idToken = await user.getIdTokenResult(); + if (idToken.expirationTime != null && + DateTime.now().difference(idToken.expirationTime!) < + const Duration(minutes: 5)) { + // Token is expiring soon, refresh it + await user.getIdTokenResult(); + } + + // Emit authenticated state + _emitSessionState(SessionState.authenticated(userId: user.uid)); + } catch (e) { + _emitSessionState(SessionState.error(e.toString())); + } + } + + /// Handle user sign-out event. + void _handleSignOut() { + _emitSessionState(SessionState.unauthenticated()); + } + + /// Emit session state update. + void _emitSessionState(SessionState state) { + _lastSessionState = state; + if (!_sessionStateController.isClosed) { + _sessionStateController.add(state); + } + } + + /// Dispose session handler resources. + Future disposeSessionHandler() async { + await _authStateSubscription?.cancel(); + await _sessionStateController.close(); + } +} diff --git a/apps/mobile/packages/data_connect/lib/src/session/client_session_store.dart b/apps/mobile/packages/data_connect/lib/src/session/client_session_store.dart index e17f22a4..529277ea 100644 --- a/apps/mobile/packages/data_connect/lib/src/session/client_session_store.dart +++ b/apps/mobile/packages/data_connect/lib/src/session/client_session_store.dart @@ -1,5 +1,3 @@ -import 'package:krow_domain/krow_domain.dart' as domain; - class ClientBusinessSession { final String id; final String businessName; @@ -19,15 +17,9 @@ class ClientBusinessSession { } class ClientSession { - final domain.User user; - final String? userPhotoUrl; final ClientBusinessSession? business; - const ClientSession({ - required this.user, - required this.userPhotoUrl, - required this.business, - }); + const ClientSession({required this.business}); } class ClientSessionStore { diff --git a/apps/mobile/packages/data_connect/lib/src/session/staff_session_store.dart b/apps/mobile/packages/data_connect/lib/src/session/staff_session_store.dart index 06be4aef..7c5229c9 100644 --- a/apps/mobile/packages/data_connect/lib/src/session/staff_session_store.dart +++ b/apps/mobile/packages/data_connect/lib/src/session/staff_session_store.dart @@ -1,18 +1,15 @@ import 'package:krow_domain/krow_domain.dart' as domain; class StaffSession { + const StaffSession({required this.user, this.staff, this.ownerId}); + final domain.User user; final domain.Staff? staff; final String? ownerId; - - const StaffSession({ - required this.user, - this.staff, - this.ownerId, - }); } class StaffSessionStore { + StaffSessionStore._(); StaffSession? _session; StaffSession? get session => _session; @@ -26,6 +23,4 @@ class StaffSessionStore { } static final StaffSessionStore instance = StaffSessionStore._(); - - StaffSessionStore._(); } diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index bbe513ae..d3b2ac2a 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -53,6 +53,10 @@ export 'src/entities/financial/invoice_item.dart'; export 'src/entities/financial/invoice_decline.dart'; export 'src/entities/financial/staff_payment.dart'; export 'src/entities/financial/payment_summary.dart'; +export 'src/entities/financial/bank_account/bank_account.dart'; +export 'src/entities/financial/bank_account/business_bank_account.dart'; +export 'src/entities/financial/bank_account/staff_bank_account.dart'; +export 'src/adapters/financial/bank_account/bank_account_adapter.dart'; // Profile export 'src/entities/profile/staff_document.dart'; @@ -68,7 +72,6 @@ export 'src/entities/ratings/business_staff_preference.dart'; // Staff Profile export 'src/entities/profile/emergency_contact.dart'; -export 'src/entities/profile/bank_account.dart'; export 'src/entities/profile/accessibility.dart'; export 'src/entities/profile/schedule.dart'; diff --git a/apps/mobile/packages/domain/lib/src/adapters/financial/bank_account/bank_account_adapter.dart b/apps/mobile/packages/domain/lib/src/adapters/financial/bank_account/bank_account_adapter.dart new file mode 100644 index 00000000..167d1126 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/adapters/financial/bank_account/bank_account_adapter.dart @@ -0,0 +1,21 @@ +import '../../../entities/financial/bank_account/business_bank_account.dart'; + +/// Adapter for [BusinessBankAccount] to map data layer values to domain entity. +class BusinessBankAccountAdapter { + /// Maps primitive values to [BusinessBankAccount]. + static BusinessBankAccount fromPrimitives({ + required String id, + required String bank, + required String last4, + required bool isPrimary, + DateTime? expiryTime, + }) { + return BusinessBankAccount( + id: id, + bankName: bank, + last4: last4, + isPrimary: isPrimary, + expiryTime: expiryTime, + ); + } +} diff --git a/apps/mobile/packages/domain/lib/src/adapters/profile/bank_account_adapter.dart b/apps/mobile/packages/domain/lib/src/adapters/profile/bank_account_adapter.dart index 6b285b8a..133da163 100644 --- a/apps/mobile/packages/domain/lib/src/adapters/profile/bank_account_adapter.dart +++ b/apps/mobile/packages/domain/lib/src/adapters/profile/bank_account_adapter.dart @@ -1,9 +1,9 @@ -import '../../entities/profile/bank_account.dart'; +import '../../entities/financial/bank_account/staff_bank_account.dart'; -/// Adapter for [BankAccount] to map data layer values to domain entity. +/// Adapter for [StaffBankAccount] to map data layer values to domain entity. class BankAccountAdapter { - /// Maps primitive values to [BankAccount]. - static BankAccount fromPrimitives({ + /// Maps primitive values to [StaffBankAccount]. + static StaffBankAccount fromPrimitives({ required String id, required String userId, required String bankName, @@ -13,7 +13,7 @@ class BankAccountAdapter { String? sortCode, bool? isPrimary, }) { - return BankAccount( + return StaffBankAccount( id: id, userId: userId, bankName: bankName, @@ -26,25 +26,25 @@ class BankAccountAdapter { ); } - static BankAccountType _stringToType(String? value) { - if (value == null) return BankAccountType.checking; + static StaffBankAccountType _stringToType(String? value) { + if (value == null) return StaffBankAccountType.checking; try { // Assuming backend enum names match or are uppercase - return BankAccountType.values.firstWhere( - (e) => e.name.toLowerCase() == value.toLowerCase(), - orElse: () => BankAccountType.other, + return StaffBankAccountType.values.firstWhere( + (StaffBankAccountType e) => e.name.toLowerCase() == value.toLowerCase(), + orElse: () => StaffBankAccountType.other, ); } catch (_) { - return BankAccountType.other; + return StaffBankAccountType.other; } } /// Converts domain type to string for backend. - static String typeToString(BankAccountType type) { + static String typeToString(StaffBankAccountType type) { switch (type) { - case BankAccountType.checking: + case StaffBankAccountType.checking: return 'CHECKING'; - case BankAccountType.savings: + case StaffBankAccountType.savings: return 'SAVINGS'; default: return 'CHECKING'; diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/bank_account.dart b/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/bank_account.dart new file mode 100644 index 00000000..04af8402 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/bank_account.dart @@ -0,0 +1,27 @@ +import 'package:equatable/equatable.dart'; + +/// Abstract base class for all types of bank accounts. +abstract class BankAccount extends Equatable { + /// Creates a [BankAccount]. + const BankAccount({ + required this.id, + required this.bankName, + required this.isPrimary, + this.last4, + }); + + /// Unique identifier. + final String id; + + /// Name of the bank or provider. + final String bankName; + + /// Whether this is the primary payment method. + final bool isPrimary; + + /// Last 4 digits of the account/card. + final String? last4; + + @override + List get props => [id, bankName, isPrimary, last4]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/business_bank_account.dart b/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/business_bank_account.dart new file mode 100644 index 00000000..8ad3d48e --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/business_bank_account.dart @@ -0,0 +1,26 @@ +import 'bank_account.dart'; + +/// Domain model representing a business bank account or payment method. +class BusinessBankAccount extends BankAccount { + /// Creates a [BusinessBankAccount]. + const BusinessBankAccount({ + required super.id, + required super.bankName, + required String last4, + required super.isPrimary, + this.expiryTime, + }) : super(last4: last4); + + /// Expiration date if applicable. + final DateTime? expiryTime; + + @override + List get props => [ + ...super.props, + expiryTime, + ]; + + /// Getter for non-nullable last4 in Business context. + @override + String get last4 => super.last4!; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/staff_bank_account.dart b/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/staff_bank_account.dart new file mode 100644 index 00000000..3f2f034e --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/financial/bank_account/staff_bank_account.dart @@ -0,0 +1,48 @@ +import 'bank_account.dart'; + +/// Type of staff bank account. +enum StaffBankAccountType { + /// Checking account. + checking, + + /// Savings account. + savings, + + /// Other type. + other, +} + +/// Domain entity representing a staff's bank account. +class StaffBankAccount extends BankAccount { + /// Creates a [StaffBankAccount]. + const StaffBankAccount({ + required super.id, + required this.userId, + required super.bankName, + required this.accountNumber, + required this.accountName, + required super.isPrimary, + super.last4, + this.sortCode, + this.type = StaffBankAccountType.checking, + }); + + /// User identifier. + final String userId; + + /// Full account number. + final String accountNumber; + + /// Name of the account holder. + final String accountName; + + /// Sort code (optional). + final String? sortCode; + + /// Account type. + final StaffBankAccountType type; + + @override + List get props => + [...super.props, userId, accountNumber, accountName, sortCode, type]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/bank_account.dart b/apps/mobile/packages/domain/lib/src/entities/profile/bank_account.dart deleted file mode 100644 index deca9a28..00000000 --- a/apps/mobile/packages/domain/lib/src/entities/profile/bank_account.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:equatable/equatable.dart'; - -/// Account type (Checking, Savings, etc). -enum BankAccountType { - checking, - savings, - other, -} - -/// Represents bank account details for payroll. -class BankAccount extends Equatable { - - const BankAccount({ - required this.id, - required this.userId, - required this.bankName, - required this.accountNumber, - required this.accountName, - this.sortCode, - this.type = BankAccountType.checking, - this.isPrimary = false, - this.last4, - }); - /// Unique identifier. - final String id; - - /// The [User] owning the account. - final String userId; - - /// Name of the bank. - final String bankName; - - /// Account number. - final String accountNumber; - - /// Name on the account. - final String accountName; - - /// Sort code (if applicable). - final String? sortCode; - - /// Type of account. - final BankAccountType type; - - /// Whether this is the primary account. - final bool isPrimary; - - /// Last 4 digits. - final String? last4; - - @override - List get props => [id, userId, bankName, accountNumber, accountName, sortCode, type, isPrimary, last4]; -} \ No newline at end of file diff --git a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index b64d9f71..4ebdc924 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -24,9 +24,8 @@ import '../../domain/repositories/auth_repository_interface.dart'; /// identity management and Krow's Data Connect SDK for storing user profile data. class AuthRepositoryImpl implements AuthRepositoryInterface { /// Creates an [AuthRepositoryImpl] with the real dependencies. - AuthRepositoryImpl({ - dc.DataConnectService? service, - }) : _service = service ?? dc.DataConnectService.instance; + AuthRepositoryImpl({dc.DataConnectService? service}) + : _service = service ?? dc.DataConnectService.instance; final dc.DataConnectService _service; @@ -36,11 +35,8 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { required String password, }) async { try { - final firebase.UserCredential credential = - await _service.auth.signInWithEmailAndPassword( - email: email, - password: password, - ); + final firebase.UserCredential credential = await _service.auth + .signInWithEmailAndPassword(email: email, password: password); final firebase.User? firebaseUser = credential.user; if (firebaseUser == null) { @@ -60,9 +56,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { technicalMessage: 'Firebase error code: ${e.code}', ); } else if (e.code == 'network-request-failed') { - throw NetworkException( - technicalMessage: 'Firebase: ${e.message}', - ); + throw NetworkException(technicalMessage: 'Firebase: ${e.message}'); } else { throw SignInFailedException( technicalMessage: 'Firebase auth error: ${e.message}', @@ -71,9 +65,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { } on domain.AppException { rethrow; } catch (e) { - throw SignInFailedException( - technicalMessage: 'Unexpected error: $e', - ); + throw SignInFailedException(technicalMessage: 'Unexpected error: $e'); } } @@ -88,11 +80,8 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { try { // Step 1: Try to create Firebase Auth user - final firebase.UserCredential credential = - await _service.auth.createUserWithEmailAndPassword( - email: email, - password: password, - ); + final firebase.UserCredential credential = await _service.auth + .createUserWithEmailAndPassword(email: email, password: password); firebaseUser = credential.user; if (firebaseUser == null) { @@ -111,9 +100,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { ); } on firebase.FirebaseAuthException catch (e) { if (e.code == 'weak-password') { - throw WeakPasswordException( - technicalMessage: 'Firebase: ${e.message}', - ); + throw WeakPasswordException(technicalMessage: 'Firebase: ${e.message}'); } else if (e.code == 'email-already-in-use') { // Email exists in Firebase Auth - try to sign in and complete registration return await _handleExistingFirebaseAccount( @@ -122,9 +109,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { companyName: companyName, ); } else if (e.code == 'network-request-failed') { - throw NetworkException( - technicalMessage: 'Firebase: ${e.message}', - ); + throw NetworkException(technicalMessage: 'Firebase: ${e.message}'); } else { throw SignUpFailedException( technicalMessage: 'Firebase auth error: ${e.message}', @@ -133,15 +118,17 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { } on domain.AppException { // Rollback for our known exceptions await _rollbackSignUp( - firebaseUser: firebaseUser, businessId: createdBusinessId); + firebaseUser: firebaseUser, + businessId: createdBusinessId, + ); rethrow; } catch (e) { // Rollback: Clean up any partially created resources await _rollbackSignUp( - firebaseUser: firebaseUser, businessId: createdBusinessId); - throw SignUpFailedException( - technicalMessage: 'Unexpected error: $e', + firebaseUser: firebaseUser, + businessId: createdBusinessId, ); + throw SignUpFailedException(technicalMessage: 'Unexpected error: $e'); } } @@ -161,16 +148,15 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { required String password, required String companyName, }) async { - developer.log('Email exists in Firebase, attempting sign-in: $email', - name: 'AuthRepository'); + developer.log( + 'Email exists in Firebase, attempting sign-in: $email', + name: 'AuthRepository', + ); try { // Try to sign in with the provided password - final firebase.UserCredential credential = - await _service.auth.signInWithEmailAndPassword( - email: email, - password: password, - ); + final firebase.UserCredential credential = await _service.auth + .signInWithEmailAndPassword(email: email, password: password); final firebase.User? firebaseUser = credential.user; if (firebaseUser == null) { @@ -180,32 +166,40 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { } // Sign-in succeeded! Check if user already has a BUSINESS account in PostgreSQL - final bool hasBusinessAccount = - await _checkBusinessUserExists(firebaseUser.uid); + final bool hasBusinessAccount = await _checkBusinessUserExists( + firebaseUser.uid, + ); if (hasBusinessAccount) { // User already has a KROW Client account - developer.log('User already has BUSINESS account: ${firebaseUser.uid}', - name: 'AuthRepository'); + developer.log( + 'User already has BUSINESS account: ${firebaseUser.uid}', + name: 'AuthRepository', + ); throw AccountExistsException( - technicalMessage: 'User ${firebaseUser.uid} already has BUSINESS role', + technicalMessage: + 'User ${firebaseUser.uid} already has BUSINESS role', ); } // User exists in Firebase but not in KROW PostgreSQL - create the entities developer.log( - 'Creating BUSINESS account for existing Firebase user: ${firebaseUser.uid}', - name: 'AuthRepository'); + 'Creating BUSINESS account for existing Firebase user: ${firebaseUser.uid}', + name: 'AuthRepository', + ); return await _createBusinessAndUser( firebaseUser: firebaseUser, companyName: companyName, email: email, - onBusinessCreated: (_) {}, // No rollback needed for existing Firebase user + onBusinessCreated: + (_) {}, // No rollback needed for existing Firebase user ); } on firebase.FirebaseAuthException catch (e) { // Sign-in failed - check why - developer.log('Sign-in failed with code: ${e.code}', - name: 'AuthRepository'); + developer.log( + 'Sign-in failed with code: ${e.code}', + name: 'AuthRepository', + ); if (e.code == 'wrong-password' || e.code == 'invalid-credential') { // Password doesn't match - check what providers are available @@ -229,8 +223,10 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // We can't distinguish between "wrong password" and "no password provider" // due to Firebase deprecating fetchSignInMethodsForEmail. // The PasswordMismatchException message covers both scenarios. - developer.log('Password mismatch or different provider for: $email', - name: 'AuthRepository'); + developer.log( + 'Password mismatch or different provider for: $email', + name: 'AuthRepository', + ); throw PasswordMismatchException( technicalMessage: 'Email $email: password mismatch or different auth provider', @@ -242,7 +238,8 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { Future _checkBusinessUserExists(String firebaseUserId) async { final QueryResult response = await _service.run( - () => _service.connector.getUserById(id: firebaseUserId).execute()); + () => _service.connector.getUserById(id: firebaseUserId).execute(), + ); final dc.GetUserByIdUser? user = response.data.user; return user != null && (user.userRole == 'BUSINESS' || user.userRole == 'BOTH'); @@ -258,14 +255,16 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // Create Business entity in PostgreSQL final OperationResult - createBusinessResponse = await _service.run(() => _service.connector - .createBusiness( - businessName: companyName, - userId: firebaseUser.uid, - rateGroup: dc.BusinessRateGroup.STANDARD, - status: dc.BusinessStatus.PENDING, - ) - .execute()); + createBusinessResponse = await _service.run( + () => _service.connector + .createBusiness( + businessName: companyName, + userId: firebaseUser.uid, + rateGroup: dc.BusinessRateGroup.STANDARD, + status: dc.BusinessStatus.PENDING, + ) + .execute(), + ); final dc.CreateBusinessBusinessInsert businessData = createBusinessResponse.data.business_insert; @@ -273,28 +272,28 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // Check if User entity already exists in PostgreSQL final QueryResult userResult = - await _service.run(() => - _service.connector.getUserById(id: firebaseUser.uid).execute()); + await _service.run( + () => _service.connector.getUserById(id: firebaseUser.uid).execute(), + ); final dc.GetUserByIdUser? existingUser = userResult.data.user; if (existingUser != null) { // User exists (likely in another app like STAFF). Update role to BOTH. - await _service.run(() => _service.connector - .updateUser( - id: firebaseUser.uid, - ) - .userRole('BOTH') - .execute()); + await _service.run( + () => _service.connector + .updateUser(id: firebaseUser.uid) + .userRole('BOTH') + .execute(), + ); } else { // Create new User entity in PostgreSQL - await _service.run(() => _service.connector - .createUser( - id: firebaseUser.uid, - role: dc.UserBaseRole.USER, - ) - .email(email) - .userRole('BUSINESS') - .execute()); + await _service.run( + () => _service.connector + .createUser(id: firebaseUser.uid, role: dc.UserBaseRole.USER) + .email(email) + .userRole('BUSINESS') + .execute(), + ); } return _getUserProfile( @@ -340,7 +339,8 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { @override Future signInWithSocial({required String provider}) { throw UnimplementedError( - 'Social authentication with $provider is not yet implemented.'); + 'Social authentication with $provider is not yet implemented.', + ); } Future _getUserProfile({ @@ -349,8 +349,9 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { bool requireBusinessRole = false, }) async { final QueryResult response = - await _service.run(() => - _service.connector.getUserById(id: firebaseUserId).execute()); + await _service.run( + () => _service.connector.getUserById(id: firebaseUserId).execute(), + ); final dc.GetUserByIdUser? user = response.data.user; if (user == null) { throw UserNotFoundException( @@ -383,22 +384,22 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { role: user.role.stringValue, ); - final QueryResult businessResponse = - await _service.run(() => _service.connector - .getBusinessesByUserId( - userId: firebaseUserId, - ) - .execute()); + final QueryResult< + dc.GetBusinessesByUserIdData, + dc.GetBusinessesByUserIdVariables + > + businessResponse = await _service.run( + () => _service.connector + .getBusinessesByUserId(userId: firebaseUserId) + .execute(), + ); final dc.GetBusinessesByUserIdBusinesses? business = businessResponse.data.businesses.isNotEmpty - ? businessResponse.data.businesses.first - : null; + ? businessResponse.data.businesses.first + : null; dc.ClientSessionStore.instance.setSession( dc.ClientSession( - user: domainUser, - userPhotoUrl: user.photoUrl, business: business == null ? null : dc.ClientBusinessSession( @@ -414,26 +415,4 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { return domainUser; } - @override - Future restoreSession() async { - final firebase.User? firebaseUser = _service.auth.currentUser; - if (firebaseUser == null) { - return null; - } - - try { - return await _getUserProfile( - firebaseUserId: firebaseUser.uid, - fallbackEmail: firebaseUser.email, - requireBusinessRole: true, - ); - } catch (e) { - // If the user is not found or other permanent errors, we should probably sign out - if (e is UserNotFoundException || e is UnauthorizedAppException) { - await _service.auth.signOut(); - return null; - } - rethrow; - } - } } diff --git a/apps/mobile/packages/features/client/authentication/lib/src/domain/repositories/auth_repository_interface.dart b/apps/mobile/packages/features/client/authentication/lib/src/domain/repositories/auth_repository_interface.dart index 3dbc053f..21a1830c 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/domain/repositories/auth_repository_interface.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/domain/repositories/auth_repository_interface.dart @@ -34,7 +34,4 @@ abstract class AuthRepositoryInterface { /// Terminates the current user session and clears authentication tokens. Future signOut(); - - /// Restores the session if a user is already logged in. - Future restoreSession(); } diff --git a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart index f866b43c..418533fd 100644 --- a/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart +++ b/apps/mobile/packages/features/client/authentication/lib/src/presentation/pages/client_intro_page.dart @@ -1,60 +1,16 @@ -import 'dart:async'; -import 'package:client_authentication/src/domain/repositories/auth_repository_interface.dart'; +import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; -class ClientIntroPage extends StatefulWidget { +class ClientIntroPage extends StatelessWidget { const ClientIntroPage({super.key}); - @override - State createState() => _ClientIntroPageState(); -} - -class _ClientIntroPageState extends State { - @override - void initState() { - super.initState(); - _checkSession(); - } - - Future _checkSession() async { - // Check session immediately without artificial delay - if (!mounted) return; - - try { - final AuthRepositoryInterface authRepo = Modular.get(); - // Add a timeout to prevent infinite loading - final user = await authRepo.restoreSession().timeout( - const Duration(seconds: 5), - onTimeout: () { - throw TimeoutException('Session restore timed out'); - }, - ); - - if (mounted) { - if (user != null) { - Modular.to.navigate(ClientPaths.home); - } else { - Modular.to.navigate(ClientPaths.getStarted); - } - } - } catch (e) { - debugPrint('ClientIntroPage: Session check error: $e'); - if (mounted) { - Modular.to.navigate(ClientPaths.getStarted); - } - } - } - @override Widget build(BuildContext context) { return Scaffold( backgroundColor: Theme.of(context).colorScheme.surface, body: Center( child: Image.asset( - 'assets/logo-blue.png', - package: 'design_system', + UiImageAssets.logoBlue, width: 120, ), ), diff --git a/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart b/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart index 8c639cb3..1acdc69b 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/billing_module.dart @@ -3,6 +3,7 @@ import 'package:krow_core/core.dart'; import 'data/repositories_impl/billing_repository_impl.dart'; import 'domain/repositories/billing_repository.dart'; +import 'domain/usecases/get_bank_accounts.dart'; import 'domain/usecases/get_current_bill_amount.dart'; import 'domain/usecases/get_invoice_history.dart'; import 'domain/usecases/get_pending_invoices.dart'; @@ -21,6 +22,7 @@ class BillingModule extends Module { i.addSingleton(BillingRepositoryImpl.new); // Use Cases + i.addSingleton(GetBankAccountsUseCase.new); i.addSingleton(GetCurrentBillAmountUseCase.new); i.addSingleton(GetSavingsAmountUseCase.new); i.addSingleton(GetPendingInvoicesUseCase.new); @@ -30,6 +32,7 @@ class BillingModule extends Module { // BLoCs i.addSingleton( () => BillingBloc( + getBankAccounts: i.get(), getCurrentBillAmount: i.get(), getSavingsAmount: i.get(), getPendingInvoices: i.get(), diff --git a/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart b/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart index d0441b26..95578127 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/data/repositories_impl/billing_repository_impl.dart @@ -16,6 +16,23 @@ class BillingRepositoryImpl implements BillingRepository { final data_connect.DataConnectService _service; + /// Fetches bank accounts associated with the business. + @override + Future> getBankAccounts() async { + return _service.run(() async { + final String businessId = await _service.getBusinessId(); + + final fdc.QueryResult< + data_connect.GetAccountsByOwnerIdData, + data_connect.GetAccountsByOwnerIdVariables> result = + await _service.connector + .getAccountsByOwnerId(ownerId: businessId) + .execute(); + + return result.data.accounts.map(_mapBankAccount).toList(); + }); + } + /// Fetches the current bill amount by aggregating open invoices. @override Future getCurrentBillAmount() async { @@ -182,6 +199,18 @@ class BillingRepositoryImpl implements BillingRepository { ); } + BusinessBankAccount _mapBankAccount( + data_connect.GetAccountsByOwnerIdAccounts account, + ) { + return BusinessBankAccountAdapter.fromPrimitives( + id: account.id, + bank: account.bank, + last4: account.last4, + isPrimary: account.isPrimary ?? false, + expiryTime: _service.toDateTime(account.expiryTime), + ); + } + InvoiceStatus _mapInvoiceStatus( data_connect.EnumValue status, ) { diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart index 4a9300d3..d631a40b 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/repositories/billing_repository.dart @@ -7,6 +7,9 @@ import '../models/billing_period.dart'; /// acting as a boundary between the Domain and Data layers. /// It allows the Domain layer to remain independent of specific data sources. abstract class BillingRepository { + /// Fetches bank accounts associated with the business. + Future> getBankAccounts(); + /// Fetches invoices that are pending approval or payment. Future> getPendingInvoices(); diff --git a/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_bank_accounts.dart b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_bank_accounts.dart new file mode 100644 index 00000000..23a52f38 --- /dev/null +++ b/apps/mobile/packages/features/client/billing/lib/src/domain/usecases/get_bank_accounts.dart @@ -0,0 +1,14 @@ +import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../repositories/billing_repository.dart'; + +/// Use case for fetching the bank accounts associated with the business. +class GetBankAccountsUseCase extends NoInputUseCase> { + /// Creates a [GetBankAccountsUseCase]. + GetBankAccountsUseCase(this._repository); + + final BillingRepository _repository; + + @override + Future> call() => _repository.getBankAccounts(); +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart index ccddda07..ee88ed63 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_bloc.dart @@ -1,6 +1,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; +import '../../domain/usecases/get_bank_accounts.dart'; import '../../domain/usecases/get_current_bill_amount.dart'; import '../../domain/usecases/get_invoice_history.dart'; import '../../domain/usecases/get_pending_invoices.dart'; @@ -16,12 +17,14 @@ class BillingBloc extends Bloc with BlocErrorHandler { /// Creates a [BillingBloc] with the given use cases. BillingBloc({ + required GetBankAccountsUseCase getBankAccounts, required GetCurrentBillAmountUseCase getCurrentBillAmount, required GetSavingsAmountUseCase getSavingsAmount, required GetPendingInvoicesUseCase getPendingInvoices, required GetInvoiceHistoryUseCase getInvoiceHistory, required GetSpendingBreakdownUseCase getSpendingBreakdown, - }) : _getCurrentBillAmount = getCurrentBillAmount, + }) : _getBankAccounts = getBankAccounts, + _getCurrentBillAmount = getCurrentBillAmount, _getSavingsAmount = getSavingsAmount, _getPendingInvoices = getPendingInvoices, _getInvoiceHistory = getInvoiceHistory, @@ -31,6 +34,7 @@ class BillingBloc extends Bloc on(_onPeriodChanged); } + final GetBankAccountsUseCase _getBankAccounts; final GetCurrentBillAmountUseCase _getCurrentBillAmount; final GetSavingsAmountUseCase _getSavingsAmount; final GetPendingInvoicesUseCase _getPendingInvoices; @@ -52,12 +56,15 @@ class BillingBloc extends Bloc _getPendingInvoices.call(), _getInvoiceHistory.call(), _getSpendingBreakdown.call(state.period), + _getBankAccounts.call(), ]); final double savings = results[1] as double; final List pendingInvoices = results[2] as List; final List invoiceHistory = results[3] as List; final List spendingItems = results[4] as List; + final List bankAccounts = + results[5] as List; // Map Domain Entities to Presentation Models final List uiPendingInvoices = @@ -79,6 +86,7 @@ class BillingBloc extends Bloc pendingInvoices: uiPendingInvoices, invoiceHistory: uiInvoiceHistory, spendingBreakdown: uiSpendingBreakdown, + bankAccounts: bankAccounts, ), ); }, diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_state.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_state.dart index d983728d..ef3ba019 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_state.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/blocs/billing_state.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; import '../../domain/models/billing_period.dart'; import '../models/billing_invoice_model.dart'; import '../models/spending_breakdown_model.dart'; @@ -28,6 +29,7 @@ class BillingState extends Equatable { this.pendingInvoices = const [], this.invoiceHistory = const [], this.spendingBreakdown = const [], + this.bankAccounts = const [], this.period = BillingPeriod.week, this.errorMessage, }); @@ -50,6 +52,9 @@ class BillingState extends Equatable { /// Breakdown of spending by category. final List spendingBreakdown; + /// Bank accounts associated with the business. + final List bankAccounts; + /// Selected period for the breakdown. final BillingPeriod period; @@ -64,6 +69,7 @@ class BillingState extends Equatable { List? pendingInvoices, List? invoiceHistory, List? spendingBreakdown, + List? bankAccounts, BillingPeriod? period, String? errorMessage, }) { @@ -74,6 +80,7 @@ class BillingState extends Equatable { pendingInvoices: pendingInvoices ?? this.pendingInvoices, invoiceHistory: invoiceHistory ?? this.invoiceHistory, spendingBreakdown: spendingBreakdown ?? this.spendingBreakdown, + bankAccounts: bankAccounts ?? this.bankAccounts, period: period ?? this.period, errorMessage: errorMessage ?? this.errorMessage, ); @@ -87,6 +94,7 @@ class BillingState extends Equatable { pendingInvoices, invoiceHistory, spendingBreakdown, + bankAccounts, period, errorMessage, ]; diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart index 6a1c2832..4771b744 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart @@ -71,19 +71,20 @@ class _BillingViewState extends State { @override Widget build(BuildContext context) { - return BlocConsumer( - listener: (BuildContext context, BillingState state) { - if (state.status == BillingStatus.failure && state.errorMessage != null) { - UiSnackbar.show( - context, - message: translateErrorKey(state.errorMessage!), - type: UiSnackbarType.error, - ); - } - }, - builder: (BuildContext context, BillingState state) { - return Scaffold( - body: CustomScrollView( + return Scaffold( + body: BlocConsumer( + listener: (BuildContext context, BillingState state) { + if (state.status == BillingStatus.failure && + state.errorMessage != null) { + UiSnackbar.show( + context, + message: translateErrorKey(state.errorMessage!), + type: UiSnackbarType.error, + ); + } + }, + builder: (BuildContext context, BillingState state) { + return CustomScrollView( controller: _scrollController, slivers: [ SliverAppBar( @@ -97,7 +98,7 @@ class _BillingViewState extends State { leading: Center( child: UiIconButton.secondary( icon: UiIcons.arrowLeft, - onTap: () => Modular.to.toClientHome() + onTap: () => Modular.to.toClientHome(), ), ), title: AnimatedSwitcher( @@ -132,8 +133,9 @@ class _BillingViewState extends State { const SizedBox(height: UiConstants.space1), Text( '\$${state.currentBill.toStringAsFixed(2)}', - style: UiTypography.display1b - .copyWith(color: UiColors.white), + style: UiTypography.display1b.copyWith( + color: UiColors.white, + ), ), const SizedBox(height: UiConstants.space2), Container( @@ -171,16 +173,14 @@ class _BillingViewState extends State { ), ), SliverList( - delegate: SliverChildListDelegate( - [ - _buildContent(context, state), - ], - ), + delegate: SliverChildListDelegate([ + _buildContent(context, state), + ]), ), ], - ), - ); - }, + ); + }, + ), ); } @@ -211,7 +211,9 @@ class _BillingViewState extends State { const SizedBox(height: UiConstants.space4), UiButton.secondary( text: 'Retry', - onPressed: () => BlocProvider.of(context).add(const BillingLoadStarted()), + onPressed: () => BlocProvider.of( + context, + ).add(const BillingLoadStarted()), ), ], ), @@ -230,8 +232,10 @@ class _BillingViewState extends State { ], const PaymentMethodCard(), const SpendingBreakdownCard(), - if (state.invoiceHistory.isEmpty) _buildEmptyState(context) - else InvoiceHistorySection(invoices: state.invoiceHistory), + if (state.invoiceHistory.isEmpty) + _buildEmptyState(context) + else + InvoiceHistorySection(invoices: state.invoiceHistory), const SizedBox(height: UiConstants.space32), ], diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/payment_method_card.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/payment_method_card.dart index 4f1c569b..346380e7 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/payment_method_card.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/payment_method_card.dart @@ -1,166 +1,133 @@ import 'package:core_localization/core_localization.dart'; import 'package:design_system/design_system.dart'; -import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; import 'package:flutter/material.dart'; -import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../blocs/billing_bloc.dart'; +import '../blocs/billing_state.dart'; /// Card showing the current payment method. -class PaymentMethodCard extends StatefulWidget { +class PaymentMethodCard extends StatelessWidget { /// Creates a [PaymentMethodCard]. const PaymentMethodCard({super.key}); - @override - State createState() => _PaymentMethodCardState(); -} - -class _PaymentMethodCardState extends State { - late final Future _accountsFuture = - _loadAccounts(); - - Future _loadAccounts() async { - final String? businessId = - dc.ClientSessionStore.instance.session?.business?.id; - if (businessId == null || businessId.isEmpty) { - return null; - } - - final fdc.QueryResult< - dc.GetAccountsByOwnerIdData, - dc.GetAccountsByOwnerIdVariables - > - result = await dc.ExampleConnector.instance - .getAccountsByOwnerId(ownerId: businessId) - .execute(); - return result.data; - } - @override Widget build(BuildContext context) { - return FutureBuilder( - future: _accountsFuture, - builder: - ( - BuildContext context, - AsyncSnapshot snapshot, - ) { - final List accounts = - snapshot.data?.accounts ?? []; - final dc.GetAccountsByOwnerIdAccounts? account = accounts.isNotEmpty - ? accounts.first - : null; + return BlocBuilder( + builder: (BuildContext context, BillingState state) { + final List accounts = state.bankAccounts; + final BusinessBankAccount? account = + accounts.isNotEmpty ? accounts.first : null; - if (account == null) { - return const SizedBox.shrink(); - } + if (account == null) { + return const SizedBox.shrink(); + } - final String bankLabel = account.bank.isNotEmpty == true - ? account.bank - : '----'; - final String last4 = account.last4.isNotEmpty == true - ? account.last4 - : '----'; - final bool isPrimary = account.isPrimary ?? false; - final String expiryLabel = _formatExpiry(account.expiryTime); + final String bankLabel = + account.bankName.isNotEmpty == true ? account.bankName : '----'; + final String last4 = + account.last4.isNotEmpty == true ? account.last4 : '----'; + final bool isPrimary = account.isPrimary; + final String expiryLabel = _formatExpiry(account.expiryTime); - return Container( - padding: const EdgeInsets.all(UiConstants.space4), - decoration: BoxDecoration( - color: UiColors.white, - borderRadius: UiConstants.radiusLg, - border: Border.all(color: UiColors.border), - boxShadow: [ - BoxShadow( - color: UiColors.black.withValues(alpha: 0.04), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], + return Container( + padding: const EdgeInsets.all(UiConstants.space4), + decoration: BoxDecoration( + color: UiColors.white, + borderRadius: UiConstants.radiusLg, + border: Border.all(color: UiColors.border), + boxShadow: [ + BoxShadow( + color: UiColors.black.withValues(alpha: 0.04), + blurRadius: 8, + offset: const Offset(0, 2), ), - child: Column( + ], + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - t.client_billing.payment_method, - style: UiTypography.title2b.textPrimary, - ), - const SizedBox.shrink(), - ], - ), - const SizedBox(height: UiConstants.space3), - Container( - padding: const EdgeInsets.all(UiConstants.space3), - decoration: BoxDecoration( - color: UiColors.bgSecondary, - borderRadius: UiConstants.radiusMd, - ), - child: Row( - children: [ - Container( - width: UiConstants.space10, - height: UiConstants.space6 + 4, - decoration: BoxDecoration( - color: UiColors.primary, - borderRadius: UiConstants.radiusSm, - ), - child: Center( - child: Text( - bankLabel, - style: UiTypography.footnote2b.white, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ), - const SizedBox(width: UiConstants.space3), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '•••• $last4', - style: UiTypography.body2b.textPrimary, - ), - Text( - t.client_billing.expires(date: expiryLabel), - style: UiTypography.footnote2r.textSecondary, - ), - ], - ), - ), - if (isPrimary) - Container( - padding: const EdgeInsets.symmetric( - horizontal: UiConstants.space2, - vertical: UiConstants.space1, - ), - decoration: BoxDecoration( - color: UiColors.accent, - borderRadius: UiConstants.radiusSm, - ), - child: Text( - t.client_billing.default_badge, - style: UiTypography.titleUppercase4b.textPrimary, - ), - ), - ], - ), + Text( + t.client_billing.payment_method, + style: UiTypography.title2b.textPrimary, ), + const SizedBox.shrink(), ], ), - ); - }, + const SizedBox(height: UiConstants.space3), + Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.bgSecondary, + borderRadius: UiConstants.radiusMd, + ), + child: Row( + children: [ + Container( + width: UiConstants.space10, + height: UiConstants.space6 + 4, + decoration: BoxDecoration( + color: UiColors.primary, + borderRadius: UiConstants.radiusSm, + ), + child: Center( + child: Text( + bankLabel, + style: UiTypography.footnote2b.white, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '•••• $last4', + style: UiTypography.body2b.textPrimary, + ), + Text( + t.client_billing.expires(date: expiryLabel), + style: UiTypography.footnote2r.textSecondary, + ), + ], + ), + ), + if (isPrimary) + Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: UiConstants.space1, + ), + decoration: BoxDecoration( + color: UiColors.accent, + borderRadius: UiConstants.radiusSm, + ), + child: Text( + t.client_billing.default_badge, + style: UiTypography.titleUppercase4b.textPrimary, + ), + ), + ], + ), + ), + ], + ), + ); + }, ); } - String _formatExpiry(fdc.Timestamp? expiryTime) { + String _formatExpiry(DateTime? expiryTime) { if (expiryTime == null) { return 'N/A'; } - final DateTime date = expiryTime.toDateTime(); - final String month = date.month.toString().padLeft(2, '0'); - final String year = (date.year % 100).toString().padLeft(2, '0'); + final String month = expiryTime.month.toString().padLeft(2, '0'); + final String year = (expiryTime.year % 100).toString().padLeft(2, '0'); return '$month/$year'; } } diff --git a/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart b/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart index cc92dbc8..7d89f676 100644 --- a/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart +++ b/apps/mobile/packages/features/client/home/lib/src/data/repositories_impl/home_repository_impl.dart @@ -19,26 +19,43 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { final DateTime now = DateTime.now(); final int daysFromMonday = now.weekday - DateTime.monday; - final DateTime monday = - DateTime(now.year, now.month, now.day).subtract(Duration(days: daysFromMonday)); - final DateTime weekRangeStart = DateTime(monday.year, monday.month, monday.day); - final DateTime weekRangeEnd = - DateTime(monday.year, monday.month, monday.day + 13, 23, 59, 59, 999); - final fdc.QueryResult completedResult = - await _service.connector - .getCompletedShiftsByBusinessId( - businessId: businessId, - dateFrom: _service.toTimestamp(weekRangeStart), - dateTo: _service.toTimestamp(weekRangeEnd), - ) - .execute(); + final DateTime monday = DateTime( + now.year, + now.month, + now.day, + ).subtract(Duration(days: daysFromMonday)); + final DateTime weekRangeStart = DateTime( + monday.year, + monday.month, + monday.day, + ); + final DateTime weekRangeEnd = DateTime( + monday.year, + monday.month, + monday.day + 13, + 23, + 59, + 59, + 999, + ); + final fdc.QueryResult< + dc.GetCompletedShiftsByBusinessIdData, + dc.GetCompletedShiftsByBusinessIdVariables + > + completedResult = await _service.connector + .getCompletedShiftsByBusinessId( + businessId: businessId, + dateFrom: _service.toTimestamp(weekRangeStart), + dateTo: _service.toTimestamp(weekRangeEnd), + ) + .execute(); double weeklySpending = 0.0; double next7DaysSpending = 0.0; int weeklyShifts = 0; int next7DaysScheduled = 0; - for (final dc.GetCompletedShiftsByBusinessIdShifts shift in completedResult.data.shifts) { + for (final dc.GetCompletedShiftsByBusinessIdShifts shift + in completedResult.data.shifts) { final DateTime? shiftDate = shift.date?.toDateTime(); if (shiftDate == null) { continue; @@ -58,17 +75,27 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { } final DateTime start = DateTime(now.year, now.month, now.day); - final DateTime end = DateTime(now.year, now.month, now.day, 23, 59, 59, 999); + final DateTime end = DateTime( + now.year, + now.month, + now.day, + 23, + 59, + 59, + 999, + ); - final fdc.QueryResult result = - await _service.connector - .listShiftRolesByBusinessAndDateRange( - businessId: businessId, - start: _service.toTimestamp(start), - end: _service.toTimestamp(end), - ) - .execute(); + final fdc.QueryResult< + dc.ListShiftRolesByBusinessAndDateRangeData, + dc.ListShiftRolesByBusinessAndDateRangeVariables + > + result = await _service.connector + .listShiftRolesByBusinessAndDateRange( + businessId: businessId, + start: _service.toTimestamp(start), + end: _service.toTimestamp(end), + ) + .execute(); int totalNeeded = 0; int totalFilled = 0; @@ -90,12 +117,47 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { } @override - UserSessionData getUserSessionData() { + Future getUserSessionData() async { final dc.ClientSession? session = dc.ClientSessionStore.instance.session; - return UserSessionData( - businessName: session?.business?.businessName ?? '', - photoUrl: null, // Business photo isn't currently in session - ); + final dc.ClientBusinessSession? business = session?.business; + + // If session data is available, return it immediately + if (business != null) { + return UserSessionData( + businessName: business.businessName, + photoUrl: business.companyLogoUrl, + ); + } + + return await _service.run(() async { + // If session is not initialized, attempt to fetch business data to populate session + final String businessId = await _service.getBusinessId(); + final fdc.QueryResult + businessResult = await _service.connector + .getBusinessById(id: businessId) + .execute(); + + if (businessResult.data.business == null) { + throw Exception('Business data not found for ID: $businessId'); + } + + final dc.ClientSession updatedSession = dc.ClientSession( + business: dc.ClientBusinessSession( + id: businessResult.data.business!.id, + businessName: businessResult.data.business?.businessName ?? '', + email: businessResult.data.business?.email ?? '', + city: businessResult.data.business?.city ?? '', + contactName: businessResult.data.business?.contactName ?? '', + companyLogoUrl: businessResult.data.business?.companyLogoUrl, + ), + ); + dc.ClientSessionStore.instance.setSession(updatedSession); + + return UserSessionData( + businessName: businessResult.data.business!.businessName, + photoUrl: businessResult.data.business!.companyLogoUrl, + ); + }); } @override @@ -108,33 +170,34 @@ class HomeRepositoryImpl implements HomeRepositoryInterface { final fdc.Timestamp startTimestamp = _service.toTimestamp(start); final fdc.Timestamp endTimestamp = _service.toTimestamp(now); - final fdc.QueryResult result = - await _service.connector - .listShiftRolesByBusinessDateRangeCompletedOrders( - businessId: businessId, - start: startTimestamp, - end: endTimestamp, - ) - .execute(); + final fdc.QueryResult< + dc.ListShiftRolesByBusinessDateRangeCompletedOrdersData, + dc.ListShiftRolesByBusinessDateRangeCompletedOrdersVariables + > + result = await _service.connector + .listShiftRolesByBusinessDateRangeCompletedOrders( + businessId: businessId, + start: startTimestamp, + end: endTimestamp, + ) + .execute(); - return result.data.shiftRoles - .map(( - dc.ListShiftRolesByBusinessDateRangeCompletedOrdersShiftRoles shiftRole, - ) { - final String location = shiftRole.shift.location ?? shiftRole.shift.locationAddress ?? ''; - final String type = shiftRole.shift.order.orderType.stringValue; - return ReorderItem( - orderId: shiftRole.shift.order.id, - title: '${shiftRole.role.name} - ${shiftRole.shift.title}', - location: location, - hourlyRate: shiftRole.role.costPerHour, - hours: shiftRole.hours ?? 0, - workers: shiftRole.count, - type: type, - ); - }) - .toList(); + return result.data.shiftRoles.map(( + dc.ListShiftRolesByBusinessDateRangeCompletedOrdersShiftRoles shiftRole, + ) { + final String location = + shiftRole.shift.location ?? shiftRole.shift.locationAddress ?? ''; + final String type = shiftRole.shift.order.orderType.stringValue; + return ReorderItem( + orderId: shiftRole.shift.order.id, + title: '${shiftRole.role.name} - ${shiftRole.shift.title}', + location: location, + hourlyRate: shiftRole.role.costPerHour, + hours: shiftRole.hours ?? 0, + workers: shiftRole.count, + type: type, + ); + }).toList(); }); } } diff --git a/apps/mobile/packages/features/client/home/lib/src/domain/repositories/home_repository_interface.dart b/apps/mobile/packages/features/client/home/lib/src/domain/repositories/home_repository_interface.dart index 22b5a7f4..e84df66a 100644 --- a/apps/mobile/packages/features/client/home/lib/src/domain/repositories/home_repository_interface.dart +++ b/apps/mobile/packages/features/client/home/lib/src/domain/repositories/home_repository_interface.dart @@ -24,7 +24,7 @@ abstract interface class HomeRepositoryInterface { Future getDashboardData(); /// Fetches the user's session data (business name and photo). - UserSessionData getUserSessionData(); + Future getUserSessionData(); /// Fetches recently completed shift roles for reorder suggestions. Future> getRecentReorders(); diff --git a/apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_user_session_data_usecase.dart b/apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_user_session_data_usecase.dart index 24d043c5..f246d856 100644 --- a/apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_user_session_data_usecase.dart +++ b/apps/mobile/packages/features/client/home/lib/src/domain/usecases/get_user_session_data_usecase.dart @@ -10,7 +10,7 @@ class GetUserSessionDataUseCase { final HomeRepositoryInterface _repository; /// Executes the use case to get session data. - UserSessionData call() { + Future call() { return _repository.getUserSessionData(); } } diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart index a2cc7629..cba07bba 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/blocs/client_home_bloc.dart @@ -40,7 +40,7 @@ class ClientHomeBloc extends Bloc emit: emit, action: () async { // Get session data - final UserSessionData sessionData = _getUserSessionDataUseCase(); + final UserSessionData sessionData = await _getUserSessionDataUseCase(); // Get dashboard data final HomeDashboardData data = await _getDashboardDataUseCase(); diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/actions_widget.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/actions_widget.dart index 04a420b7..3af93fc3 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/actions_widget.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/actions_widget.dart @@ -4,7 +4,6 @@ import 'package:flutter/material.dart'; /// A widget that displays quick actions for the client. class ActionsWidget extends StatelessWidget { - /// Creates an [ActionsWidget]. const ActionsWidget({ super.key, @@ -12,6 +11,7 @@ class ActionsWidget extends StatelessWidget { required this.onCreateOrderPressed, this.subtitle, }); + /// Callback when RAPID is pressed. final VoidCallback onRapidPressed; @@ -26,12 +26,9 @@ class ActionsWidget extends StatelessWidget { // Check if client_home exists in t final TranslationsClientHomeActionsEn i18n = t.client_home.actions; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, + return Row( + spacing: UiConstants.space4, children: [ - Row( - children: [ - /// TODO: FEATURE_NOT_YET_IMPLEMENTED Expanded( child: _ActionCard( title: i18n.rapid, @@ -46,7 +43,6 @@ class ActionsWidget extends StatelessWidget { onTap: onRapidPressed, ), ), - // const SizedBox(width: UiConstants.space2), Expanded( child: _ActionCard( title: i18n.create_order, @@ -62,14 +58,11 @@ class ActionsWidget extends StatelessWidget { ), ), ], - ), - ], ); } } class _ActionCard extends StatelessWidget { - const _ActionCard({ required this.title, required this.subtitle, diff --git a/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart b/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart index d207b7d5..91de3bdf 100644 --- a/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart +++ b/apps/mobile/packages/features/client/hubs/lib/src/data/repositories_impl/hub_repository_impl.dart @@ -16,16 +16,16 @@ import '../../domain/repositories/hub_repository_interface.dart'; /// Implementation of [HubRepositoryInterface] backed by Data Connect. class HubRepositoryImpl implements HubRepositoryInterface { - HubRepositoryImpl({ - required dc.DataConnectService service, - }) : _service = service; + HubRepositoryImpl({required dc.DataConnectService service}) + : _service = service; final dc.DataConnectService _service; @override Future> getHubs() async { return _service.run(() async { - final dc.GetBusinessesByUserIdBusinesses business = await _getBusinessForCurrentUser(); + final dc.GetBusinessesByUserIdBusinesses business = + await _getBusinessForCurrentUser(); final String teamId = await _getOrCreateTeamId(business); return _fetchHubsForTeam(teamId: teamId, businessId: business.id); }); @@ -45,10 +45,12 @@ class HubRepositoryImpl implements HubRepositoryInterface { String? zipCode, }) async { return _service.run(() async { - final dc.GetBusinessesByUserIdBusinesses business = await _getBusinessForCurrentUser(); + final dc.GetBusinessesByUserIdBusinesses business = + await _getBusinessForCurrentUser(); final String teamId = await _getOrCreateTeamId(business); - final _PlaceAddress? placeAddress = - placeId == null || placeId.isEmpty ? null : await _fetchPlaceAddress(placeId); + final _PlaceAddress? placeAddress = placeId == null || placeId.isEmpty + ? null + : await _fetchPlaceAddress(placeId); final String? cityValue = city ?? placeAddress?.city ?? business.city; final String? stateValue = state ?? placeAddress?.state; final String? streetValue = street ?? placeAddress?.street; @@ -56,21 +58,17 @@ class HubRepositoryImpl implements HubRepositoryInterface { final String? zipCodeValue = zipCode ?? placeAddress?.zipCode; final OperationResult - result = await _service.connector - .createTeamHub( - teamId: teamId, - hubName: name, - address: address, - ) - .placeId(placeId) - .latitude(latitude) - .longitude(longitude) - .city(cityValue?.isNotEmpty == true ? cityValue : '') - .state(stateValue) - .street(streetValue) - .country(countryValue) - .zipCode(zipCodeValue) - .execute(); + result = await _service.connector + .createTeamHub(teamId: teamId, hubName: name, address: address) + .placeId(placeId) + .latitude(latitude) + .longitude(longitude) + .city(cityValue?.isNotEmpty == true ? cityValue : '') + .state(stateValue) + .street(streetValue) + .country(countryValue) + .zipCode(zipCodeValue) + .execute(); final String createdId = result.data.teamHub_insert.id; final List hubs = await _fetchHubsForTeam( @@ -101,14 +99,13 @@ class HubRepositoryImpl implements HubRepositoryInterface { return _service.run(() async { final String businessId = await _service.getBusinessId(); - final QueryResult result = - await _service.connector - .listOrdersByBusinessAndTeamHub( - businessId: businessId, - teamHubId: id, - ) - .execute(); + final QueryResult< + dc.ListOrdersByBusinessAndTeamHubData, + dc.ListOrdersByBusinessAndTeamHubVariables + > + result = await _service.connector + .listOrdersByBusinessAndTeamHub(businessId: businessId, teamHubId: id) + .execute(); if (result.data.orders.isNotEmpty) { throw HubHasOrdersException( @@ -121,14 +118,14 @@ class HubRepositoryImpl implements HubRepositoryInterface { } @override - Future assignNfcTag({ - required String hubId, - required String nfcTagId, - }) { - throw UnimplementedError('NFC tag assignment is not supported for team hubs.'); + Future assignNfcTag({required String hubId, required String nfcTagId}) { + throw UnimplementedError( + 'NFC tag assignment is not supported for team hubs.', + ); } - Future _getBusinessForCurrentUser() async { + Future + _getBusinessForCurrentUser() async { final dc.ClientSession? session = dc.ClientSessionStore.instance.session; final dc.ClientBusinessSession? cachedBusiness = session?.business; if (cachedBusiness != null) { @@ -136,7 +133,9 @@ class HubRepositoryImpl implements HubRepositoryInterface { id: cachedBusiness.id, businessName: cachedBusiness.businessName, userId: _service.auth.currentUser?.uid ?? '', - rateGroup: const dc.Known(dc.BusinessRateGroup.STANDARD), + rateGroup: const dc.Known( + dc.BusinessRateGroup.STANDARD, + ), status: const dc.Known(dc.BusinessStatus.ACTIVE), contactName: cachedBusiness.contactName, companyLogoUrl: cachedBusiness.companyLogoUrl, @@ -160,11 +159,13 @@ class HubRepositoryImpl implements HubRepositoryInterface { ); } - final QueryResult result = - await _service.connector.getBusinessesByUserId( - userId: user.uid, - ).execute(); + final QueryResult< + dc.GetBusinessesByUserIdData, + dc.GetBusinessesByUserIdVariables + > + result = await _service.connector + .getBusinessesByUserId(userId: user.uid) + .execute(); if (result.data.businesses.isEmpty) { await _service.auth.signOut(); throw BusinessNotFoundException( @@ -172,12 +173,11 @@ class HubRepositoryImpl implements HubRepositoryInterface { ); } - final dc.GetBusinessesByUserIdBusinesses business = result.data.businesses.first; + final dc.GetBusinessesByUserIdBusinesses business = + result.data.businesses.first; if (session != null) { dc.ClientSessionStore.instance.setSession( dc.ClientSession( - user: session.user, - userPhotoUrl: session.userPhotoUrl, business: dc.ClientBusinessSession( id: business.id, businessName: business.businessName, @@ -197,26 +197,26 @@ class HubRepositoryImpl implements HubRepositoryInterface { dc.GetBusinessesByUserIdBusinesses business, ) async { final QueryResult - teamsResult = await _service.connector.getTeamsByOwnerId( - ownerId: business.id, - ).execute(); + teamsResult = await _service.connector + .getTeamsByOwnerId(ownerId: business.id) + .execute(); if (teamsResult.data.teams.isNotEmpty) { return teamsResult.data.teams.first.id; } - final dc.CreateTeamVariablesBuilder createTeamBuilder = _service.connector.createTeam( - teamName: '${business.businessName} Team', - ownerId: business.id, - ownerName: business.contactName ?? '', - ownerRole: 'OWNER', - ); + final dc.CreateTeamVariablesBuilder createTeamBuilder = _service.connector + .createTeam( + teamName: '${business.businessName} Team', + ownerId: business.id, + ownerName: business.contactName ?? '', + ownerRole: 'OWNER', + ); if (business.email != null) { createTeamBuilder.email(business.email); } final OperationResult - createTeamResult = - await createTeamBuilder.execute(); + createTeamResult = await createTeamBuilder.execute(); final String teamId = createTeamResult.data.team_insert.id; return teamId; @@ -226,11 +226,13 @@ class HubRepositoryImpl implements HubRepositoryInterface { required String teamId, required String businessId, }) async { - final QueryResult hubsResult = - await _service.connector.getTeamHubsByTeamId( - teamId: teamId, - ).execute(); + final QueryResult< + dc.GetTeamHubsByTeamIdData, + dc.GetTeamHubsByTeamIdVariables + > + hubsResult = await _service.connector + .getTeamHubsByTeamId(teamId: teamId) + .execute(); return hubsResult.data.teamHubs .map( @@ -240,10 +242,9 @@ class HubRepositoryImpl implements HubRepositoryInterface { name: hub.hubName, address: hub.address, nfcTagId: null, - status: - hub.isActive - ? domain.HubStatus.active - : domain.HubStatus.inactive, + status: hub.isActive + ? domain.HubStatus.active + : domain.HubStatus.inactive, ), ) .toList(); @@ -288,7 +289,8 @@ class HubRepositoryImpl implements HubRepositoryInterface { for (final dynamic entry in components) { final Map component = entry as Map; - final List types = component['types'] as List? ?? []; + final List types = + component['types'] as List? ?? []; final String? longName = component['long_name'] as String?; final String? shortName = component['short_name'] as String?; diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart index 44977e55..edf6b8e3 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/pages/client_settings_page.dart @@ -31,7 +31,7 @@ class ClientSettingsPage extends StatelessWidget { message: 'Signed out successfully', type: UiSnackbarType.success, ); - Modular.to.toClientRoot(); + Modular.to.toClientGetStartedPage(); } if (state is ClientSettingsError) { UiSnackbar.show( diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart index 0d2db204..b9ddd93e 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart @@ -17,8 +17,8 @@ class SettingsProfileHeader extends StatelessWidget { final dc.ClientSession? session = dc.ClientSessionStore.instance.session; final String businessName = session?.business?.businessName ?? 'Your Company'; - final String email = session?.user.email ?? 'client@example.com'; - final String? photoUrl = session?.userPhotoUrl; + final String email = session?.business?.email ?? 'client@example.com'; + final String? photoUrl = session?.business?.companyLogoUrl; final String avatarLetter = businessName.trim().isNotEmpty ? businessName.trim()[0].toUpperCase() : 'C'; diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index 863d815f..e2dab61b 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -17,9 +17,8 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { Completer? _pendingVerification; @override - Stream get currentUser => _service.auth - .authStateChanges() - .map((User? firebaseUser) { + Stream get currentUser => + _service.auth.authStateChanges().map((User? firebaseUser) { if (firebaseUser == null) { return null; } @@ -49,20 +48,24 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { // For real numbers, we can support auto-verification if desired. // But since this method returns a verificationId for manual OTP entry, // we might not handle direct sign-in here unless the architecture changes. - // Currently, we just ignore it for the completer flow, + // Currently, we just ignore it for the completer flow, // or we could sign in directly if the credential is provided. }, verificationFailed: (FirebaseAuthException e) { if (!completer.isCompleted) { // Map Firebase network errors to NetworkException - if (e.code == 'network-request-failed' || + if (e.code == 'network-request-failed' || e.message?.contains('Unable to resolve host') == true) { completer.completeError( - const domain.NetworkException(technicalMessage: 'Auth network failure'), + const domain.NetworkException( + technicalMessage: 'Auth network failure', + ), ); } else { completer.completeError( - domain.SignInFailedException(technicalMessage: 'Firebase ${e.code}: ${e.message}'), + domain.SignInFailedException( + technicalMessage: 'Firebase ${e.code}: ${e.message}', + ), ); } } @@ -110,21 +113,18 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { verificationId: verificationId, smsCode: smsCode, ); - final UserCredential userCredential = await _service.run( - () async { - try { - return await _service.auth.signInWithCredential(credential); - } on FirebaseAuthException catch (e) { - if (e.code == 'invalid-verification-code') { - throw const domain.InvalidCredentialsException( - technicalMessage: 'Invalid OTP code entered.', - ); - } - rethrow; + final UserCredential userCredential = await _service.run(() async { + try { + return await _service.auth.signInWithCredential(credential); + } on FirebaseAuthException catch (e) { + if (e.code == 'invalid-verification-code') { + throw const domain.InvalidCredentialsException( + technicalMessage: 'Invalid OTP code entered.', + ); } - }, - requiresAuthentication: false, - ); + rethrow; + } + }, requiresAuthentication: false); final User? firebaseUser = userCredential.user; if (firebaseUser == null) { throw const domain.SignInFailedException( @@ -135,13 +135,9 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { final QueryResult response = await _service.run( - () => _service.connector - .getUserById( - id: firebaseUser.uid, - ) - .execute(), - requiresAuthentication: false, - ); + () => _service.connector.getUserById(id: firebaseUser.uid).execute(), + requiresAuthentication: false, + ); final GetUserByIdUser? user = response.data.user; GetStaffByUserIdStaffs? staffRecord; @@ -150,10 +146,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { if (user == null) { await _service.run( () => _service.connector - .createUser( - id: firebaseUser.uid, - role: UserBaseRole.USER, - ) + .createUser(id: firebaseUser.uid, role: UserBaseRole.USER) .userRole('STAFF') .execute(), requiresAuthentication: false, @@ -161,11 +154,9 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { } else { // User exists in PostgreSQL. Check if they have a STAFF profile. final QueryResult - staffResponse = await _service.run( + staffResponse = await _service.run( () => _service.connector - .getStaffByUserId( - userId: firebaseUser.uid, - ) + .getStaffByUserId(userId: firebaseUser.uid) .execute(), requiresAuthentication: false, ); @@ -208,11 +199,9 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { } final QueryResult - staffResponse = await _service.run( + staffResponse = await _service.run( () => _service.connector - .getStaffByUserId( - userId: firebaseUser.uid, - ) + .getStaffByUserId(userId: firebaseUser.uid) .execute(), requiresAuthentication: false, ); @@ -257,77 +246,4 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { ); return domainUser; } - @override - Future restoreSession() async { - final User? firebaseUser = _service.auth.currentUser; - if (firebaseUser == null) { - return null; - } - - try { - // 1. Fetch User - final QueryResult response = - await _service.run(() => _service.connector - .getUserById( - id: firebaseUser.uid, - ) - .execute()); - final GetUserByIdUser? user = response.data.user; - - if (user == null) { - return null; - } - - // 2. Check Role - if (user.userRole != 'STAFF' && user.userRole != 'BOTH') { - return null; - } - - // 3. Fetch Staff Profile - final QueryResult - staffResponse = await _service.run(() => _service.connector - .getStaffByUserId( - userId: firebaseUser.uid, - ) - .execute()); - - if (staffResponse.data.staffs.isEmpty) { - return null; - } - - final GetStaffByUserIdStaffs staffRecord = staffResponse.data.staffs.first; - - // 4. Populate Session - final domain.User domainUser = domain.User( - id: firebaseUser.uid, - email: user.email ?? '', - phone: firebaseUser.phoneNumber, - role: user.role.stringValue, - ); - - final domain.Staff domainStaff = domain.Staff( - id: staffRecord.id, - authProviderId: staffRecord.userId, - name: staffRecord.fullName, - email: staffRecord.email ?? '', - phone: staffRecord.phone, - status: domain.StaffStatus.completedProfile, - address: staffRecord.addres, - avatar: staffRecord.photoUrl, - ); - - StaffSessionStore.instance.setSession( - StaffSession( - user: domainUser, - staff: domainStaff, - ownerId: staffRecord.ownerId, - ), - ); - - return domainUser; - } catch (e) { - // If restoration fails (network, etc), we rethrow to let UI handle it. - rethrow; - } - } } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart index e73be91d..0ee6fc5a 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart @@ -20,7 +20,4 @@ abstract interface class AuthRepositoryInterface { /// Signs out the current user. Future signOut(); - - /// Restores the session if a user is already logged in. - Future restoreSession(); } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/intro_page.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/intro_page.dart index 6d27ee1b..0acc300b 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/intro_page.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/intro_page.dart @@ -1,65 +1,14 @@ -import 'dart:async'; +import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_modular/flutter_modular.dart'; -import 'package:krow_core/core.dart'; -import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart'; -class IntroPage extends StatefulWidget { +/// A simple introductory page that displays the KROW logo. +class IntroPage extends StatelessWidget { const IntroPage({super.key}); - @override - State createState() => _IntroPageState(); -} - -class _IntroPageState extends State { - @override - void initState() { - super.initState(); - _checkSession(); - } - - Future _checkSession() async { - // Check session immediately without artificial delay - if (!mounted) return; - - try { - final AuthRepositoryInterface authRepo = Modular.get(); - // Add a timeout to prevent infinite loading - final user = await authRepo.restoreSession().timeout( - const Duration(seconds: 5), - onTimeout: () { - // If it takes too long, navigate to Get Started. - // This handles poor network conditions gracefully. - throw TimeoutException('Session restore timed out'); - }, - ); - - if (mounted) { - if (user != null) { - Modular.to.navigate(StaffPaths.home); - } else { - Modular.to.navigate(StaffPaths.getStarted); - } - } - } catch (e) { - debugPrint('IntroPage: Session check error: $e'); - if (mounted) { - Modular.to.navigate(StaffPaths.getStarted); - } - } - } - @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: Theme.of(context).colorScheme.surface, - body: Center( - child: Image.asset( - 'assets/logo-yellow.png', - package: 'design_system', - width: 120, - ), - ), + body: Center(child: Image.asset(UiImageAssets.logoYellow, width: 120)), ); } } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart index 9cbf1455..109761aa 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart @@ -58,15 +58,14 @@ class _PhoneVerificationPageState extends State { } if (normalized.length == 10) { - BlocProvider.of( - context, - ).add( + BlocProvider.of(context).add( AuthSignInRequested(phoneNumber: '+1$normalized', mode: widget.mode), ); } else { UiSnackbar.show( context, - message: t.staff_authentication.phone_verification_page.validation_error, + message: + t.staff_authentication.phone_verification_page.validation_error, type: UiSnackbarType.error, margin: const EdgeInsets.only(bottom: 180, left: 16, right: 16), ); @@ -79,9 +78,7 @@ class _PhoneVerificationPageState extends State { required String otp, required String verificationId, }) { - BlocProvider.of( - context, - ).add( + BlocProvider.of(context).add( AuthOtpSubmitted( verificationId: verificationId, smsCode: otp, @@ -92,9 +89,9 @@ class _PhoneVerificationPageState extends State { /// Handles the request to resend the verification code using the phone number in the state. void _onResend({required BuildContext context}) { - BlocProvider.of(context).add( - AuthSignInRequested(mode: widget.mode), - ); + BlocProvider.of( + context, + ).add(AuthSignInRequested(mode: widget.mode)); } @override @@ -108,8 +105,6 @@ class _PhoneVerificationPageState extends State { if (state.status == AuthStatus.authenticated) { if (state.mode == AuthMode.signup) { Modular.to.toProfileSetup(); - } else { - Modular.to.toStaffHome(); } } else if (state.status == AuthStatus.error && state.mode == AuthMode.signup) { @@ -120,7 +115,11 @@ class _PhoneVerificationPageState extends State { context, message: translateErrorKey(messageKey), type: UiSnackbarType.error, - margin: const EdgeInsets.only(bottom: 180, left: 16, right: 16), + margin: const EdgeInsets.only( + bottom: 180, + left: 16, + right: 16, + ), ); Future.delayed(const Duration(seconds: 5), () { if (!mounted) return; @@ -153,9 +152,9 @@ class _PhoneVerificationPageState extends State { centerTitle: true, showBackButton: true, onLeadingPressed: () { - BlocProvider.of(context).add( - AuthResetRequested(mode: widget.mode), - ); + BlocProvider.of( + context, + ).add(AuthResetRequested(mode: widget.mode)); Navigator.of(context).pop(); }, ), @@ -175,13 +174,13 @@ class _PhoneVerificationPageState extends State { verificationId: state.verificationId ?? '', ), ) - : PhoneInput( - state: state, - onSendCode: (String phoneNumber) => _onSendCode( - context: context, - phoneNumber: phoneNumber, + : PhoneInput( + state: state, + onSendCode: (String phoneNumber) => _onSendCode( + context: context, + phoneNumber: phoneNumber, + ), ), - ), ), ), ); diff --git a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart index 344f15b9..f16beaec 100644 --- a/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart +++ b/apps/mobile/packages/features/staff/profile/lib/src/presentation/pages/staff_profile_page.dart @@ -1,22 +1,21 @@ +import 'package:core_localization/core_localization.dart'; +import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart' hide ReadContext; import 'package:flutter_modular/flutter_modular.dart'; -import 'package:core_localization/core_localization.dart'; -import 'package:design_system/design_system.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import '../blocs/profile_cubit.dart'; import '../blocs/profile_state.dart'; -import 'package:krow_core/core.dart'; +import '../widgets/language_selector_bottom_sheet.dart'; import '../widgets/logout_button.dart'; +import '../widgets/profile_header.dart'; import '../widgets/profile_menu_grid.dart'; import '../widgets/profile_menu_item.dart'; -import '../widgets/profile_header.dart'; import '../widgets/reliability_score_bar.dart'; import '../widgets/reliability_stats_card.dart'; -import '../widgets/reliability_stats_card.dart'; import '../widgets/section_title.dart'; -import '../widgets/language_selector_bottom_sheet.dart'; /// The main Staff Profile page. /// @@ -63,7 +62,7 @@ class StaffProfilePage extends StatelessWidget { bloc: cubit, listener: (context, state) { if (state.status == ProfileStatus.signedOut) { - Modular.to.toGetStarted(); + Modular.to.toGetStartedPage(); } else if (state.status == ProfileStatus.error && state.errorMessage != null) { UiSnackbar.show( diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/data/repositories/bank_account_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/data/repositories/bank_account_repository_impl.dart index 14614b66..b029f4ed 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/data/repositories/bank_account_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/data/repositories/bank_account_repository_impl.dart @@ -14,13 +14,10 @@ class BankAccountRepositoryImpl implements BankAccountRepository { final DataConnectService _service; @override - Future> getAccounts() async { + Future> getAccounts() async { return _service.run(() async { final String staffId = await _service.getStaffId(); - var x = staffId; - - print(x); final QueryResult result = await _service.connector .getAccountsByOwnerId(ownerId: staffId) @@ -44,7 +41,7 @@ class BankAccountRepositoryImpl implements BankAccountRepository { } @override - Future addAccount(BankAccount account) async { + Future addAccount(StaffBankAccount account) async { return _service.run(() async { final String staffId = await _service.getStaffId(); diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/arguments/add_bank_account_params.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/arguments/add_bank_account_params.dart index ead4135d..4bce8605 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/arguments/add_bank_account_params.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/arguments/add_bank_account_params.dart @@ -4,7 +4,7 @@ import 'package:krow_domain/krow_domain.dart'; /// Arguments for adding a bank account. class AddBankAccountParams extends UseCaseArgument with EquatableMixin { - final BankAccount account; + final StaffBankAccount account; const AddBankAccountParams({required this.account}); diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/repositories/bank_account_repository.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/repositories/bank_account_repository.dart index 3e701aba..51d72774 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/repositories/bank_account_repository.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/repositories/bank_account_repository.dart @@ -3,8 +3,8 @@ import 'package:krow_domain/krow_domain.dart'; /// Repository interface for managing bank accounts. abstract class BankAccountRepository { /// Fetches the list of bank accounts for the current user. - Future> getAccounts(); + Future> getAccounts(); /// adds a new bank account. - Future addAccount(BankAccount account); + Future addAccount(StaffBankAccount account); } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/get_bank_accounts_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/get_bank_accounts_usecase.dart index 2ee64df3..2de67941 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/get_bank_accounts_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/domain/usecases/get_bank_accounts_usecase.dart @@ -3,13 +3,13 @@ import 'package:krow_domain/krow_domain.dart'; import '../repositories/bank_account_repository.dart'; /// Use case to fetch bank accounts. -class GetBankAccountsUseCase implements NoInputUseCase> { +class GetBankAccountsUseCase implements NoInputUseCase> { final BankAccountRepository _repository; GetBankAccountsUseCase(this._repository); @override - Future> call() { + Future> call() { return _repository.getAccounts(); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_cubit.dart index f159781e..afa3c888 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_cubit.dart @@ -23,19 +23,15 @@ class BankAccountCubit extends Cubit await handleError( emit: emit, action: () async { - final List accounts = await _getBankAccountsUseCase(); + final List accounts = await _getBankAccountsUseCase(); emit( - state.copyWith( - status: BankAccountStatus.loaded, - accounts: accounts, - ), + state.copyWith(status: BankAccountStatus.loaded, accounts: accounts), ); }, - onError: - (String errorKey) => state.copyWith( - status: BankAccountStatus.error, - errorMessage: errorKey, - ), + onError: (String errorKey) => state.copyWith( + status: BankAccountStatus.error, + errorMessage: errorKey, + ), ); } @@ -52,21 +48,18 @@ class BankAccountCubit extends Cubit emit(state.copyWith(status: BankAccountStatus.loading)); // Create domain entity - final BankAccount newAccount = BankAccount( + final StaffBankAccount newAccount = StaffBankAccount( id: '', // Generated by server usually userId: '', // Handled by Repo/Auth bankName: bankName, - accountNumber: accountNumber, + accountNumber: accountNumber.length > 4 + ? accountNumber.substring(accountNumber.length - 4) + : accountNumber, accountName: '', sortCode: routingNumber, - type: - type == 'CHECKING' - ? BankAccountType.checking - : BankAccountType.savings, - last4: - accountNumber.length > 4 - ? accountNumber.substring(accountNumber.length - 4) - : accountNumber, + type: type == 'CHECKING' + ? StaffBankAccountType.checking + : StaffBankAccountType.savings, isPrimary: false, ); @@ -85,12 +78,10 @@ class BankAccountCubit extends Cubit ), ); }, - onError: - (String errorKey) => state.copyWith( - status: BankAccountStatus.error, - errorMessage: errorKey, - ), + onError: (String errorKey) => state.copyWith( + status: BankAccountStatus.error, + errorMessage: errorKey, + ), ); } } - diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_state.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_state.dart index 09038616..3073c78b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/blocs/bank_account_state.dart @@ -5,7 +5,7 @@ enum BankAccountStatus { initial, loading, loaded, error, accountAdded } class BankAccountState extends Equatable { final BankAccountStatus status; - final List accounts; + final List accounts; final String? errorMessage; final bool showForm; @@ -18,7 +18,7 @@ class BankAccountState extends Equatable { BankAccountState copyWith({ BankAccountStatus? status, - List? accounts, + List? accounts, String? errorMessage, bool? showForm, }) { diff --git a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart index 53b92702..698cfb6b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/finances/staff_bank_account/lib/src/presentation/pages/bank_account_page.dart @@ -96,7 +96,7 @@ class BankAccountPage extends StatelessWidget { style: UiTypography.headline4m.copyWith(color: UiColors.textPrimary), ), const SizedBox(height: UiConstants.space3), - ...state.accounts.map((BankAccount a) => _buildAccountCard(a, strings)), // Added type + ...state.accounts.map((StaffBankAccount a) => _buildAccountCard(a, strings)), // Added type // Add extra padding at bottom const SizedBox(height: UiConstants.space20), @@ -183,7 +183,7 @@ class BankAccountPage extends StatelessWidget { ); } - Widget _buildAccountCard(BankAccount account, dynamic strings) { + Widget _buildAccountCard(StaffBankAccount account, dynamic strings) { final bool isPrimary = account.isPrimary; const Color primaryColor = UiColors.primary; diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index f30d02fc..25c3fd23 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -141,10 +141,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" charcode: dependency: transitive description: @@ -741,6 +741,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" json_annotation: dependency: transitive description: @@ -809,18 +817,18 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" melos: dependency: "direct dev" description: @@ -1318,26 +1326,26 @@ packages: dependency: transitive description: name: test - sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.29.0" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.15" + version: "0.6.12" typed_data: dependency: transitive description: diff --git a/makefiles/mobile.mk b/makefiles/mobile.mk index f4d62624..43c3d618 100644 --- a/makefiles/mobile.mk +++ b/makefiles/mobile.mk @@ -1,6 +1,6 @@ # --- Mobile App Development --- -.PHONY: mobile-install mobile-info mobile-client-dev-android mobile-staff-dev-android mobile-client-build mobile-staff-build mobile-hot-reload mobile-hot-restart +.PHONY: mobile-install mobile-info mobile-analyze mobile-client-dev-android mobile-staff-dev-android mobile-client-build mobile-staff-build mobile-hot-reload mobile-hot-restart MOBILE_DIR := apps/mobile @@ -19,6 +19,10 @@ mobile-info: @echo "--> Fetching mobile command info..." @cd $(MOBILE_DIR) && melos run info +mobile-analyze: + @echo "--> Analyzing mobile workspace for compile-time errors..." + @cd $(MOBILE_DIR) && flutter analyze + # --- Hot Reload & Restart --- mobile-hot-reload: @echo "--> Triggering hot reload for running Flutter app..."