Merge pull request #431 from Oloodi/408-feature-implement-paidunpaid-breaks---client-app-frontend-development

Enable session persistance for the staff and client mobile applications
This commit is contained in:
Achintha Isuru
2026-02-17 16:15:43 -05:00
committed by GitHub
55 changed files with 1465 additions and 830 deletions

View File

@@ -6,6 +6,7 @@ analyzer:
- "**/*.g.dart" - "**/*.g.dart"
- "**/*.freezed.dart" - "**/*.freezed.dart"
- "**/*.config.dart" - "**/*.config.dart"
- "apps/mobile/prototypes/**"
errors: errors:
# Set the severity of the always_specify_types rule to warning as requested. # Set the severity of the always_specify_types rule to warning as requested.
always_specify_types: warning always_specify_types: warning

View File

@@ -14,8 +14,10 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
import 'src/widgets/session_listener.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -28,8 +30,18 @@ void main() async {
logEvents: true, logEvents: true,
logStateChanges: false, // Set to true for verbose debugging logStateChanges: false, // Set to true for verbose debugging
); );
// Initialize session listener for Firebase Auth state changes
DataConnectService.instance.initializeAuthListener(
allowedRoles: <String>['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. /// The main application module for the Client app.

View File

@@ -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<SessionListener> createState() => _SessionListenerState();
}
class _SessionListenerState extends State<SessionListener> {
late StreamSubscription<SessionState> _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<void>(
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: <Widget>[
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<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Session Error'),
content: Text(errorMessage),
actions: <Widget>[
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;
}

View File

@@ -41,6 +41,7 @@ dependencies:
flutter_localizations: flutter_localizations:
sdk: flutter sdk: flutter
firebase_core: ^4.4.0 firebase_core: ^4.4.0
krow_data_connect: ^0.0.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

@@ -5,25 +5,36 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_modular/flutter_modular.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:krowwithus_staff/firebase_options.dart';
import 'package:staff_authentication/staff_authentication.dart' import 'package:staff_authentication/staff_authentication.dart'
as staff_authentication; as staff_authentication;
import 'package:staff_main/staff_main.dart' as staff_main; import 'package:staff_main/staff_main.dart' as staff_main;
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'src/widgets/session_listener.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp( await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
options: DefaultFirebaseOptions.currentPlatform,
);
// Register global BLoC observer for centralized error logging // Register global BLoC observer for centralized error logging
Bloc.observer = CoreBlocObserver( Bloc.observer = CoreBlocObserver(
logEvents: true, logEvents: true,
logStateChanges: false, // Set to true for verbose debugging 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: <String>['STAFF', 'BOTH'], // Only allow users with STAFF or BOTH roles
);
runApp(
ModularApp(
module: AppModule(),
child: const SessionListener(child: AppWidget()),
),
);
} }
/// The main application module. /// The main application module.
@@ -34,7 +45,10 @@ class AppModule extends Module {
@override @override
void routes(RouteManager r) { void routes(RouteManager r) {
// Set the initial route to the authentication module // 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()); r.module(StaffPaths.main, module: staff_main.StaffMainModule());
} }

View File

@@ -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<SessionListener> createState() => _SessionListenerState();
}
class _SessionListenerState extends State<SessionListener> {
late StreamSubscription<SessionState> _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<void>(
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: <Widget>[
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<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Session Error'),
content: Text(errorMessage),
actions: <Widget>[
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;
}

View File

@@ -28,6 +28,8 @@ dependencies:
path: ../../packages/features/staff/staff_main path: ../../packages/features/staff/staff_main
krow_core: krow_core:
path: ../../packages/core path: ../../packages/core
krow_data_connect:
path: ../../packages/data_connect
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
flutter_modular: ^6.3.0 flutter_modular: ^6.3.0
firebase_core: ^4.4.0 firebase_core: ^4.4.0

View File

@@ -21,20 +21,27 @@ import 'route_paths.dart';
/// ///
/// See also: /// See also:
/// * [ClientPaths] for route path constants /// * [ClientPaths] for route path constants
/// * [StaffNavigator] for Staff app navigation /// * [ClientNavigator] for Client app navigation
extension ClientNavigator on IModularNavigator { extension ClientNavigator on IModularNavigator {
// ========================================================================== // ==========================================================================
// AUTHENTICATION FLOWS // AUTHENTICATION FLOWS
// ========================================================================== // ==========================================================================
/// Navigate to the root authentication screen. /// Navigate to the root authentication screen.
/// ///
/// This effectively logs out the user by navigating to root. /// This effectively logs out the user by navigating to root.
/// Used when signing out or session expires. /// Used when signing out or session expires.
void toClientRoot() { void toClientRoot() {
navigate(ClientPaths.root); 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. /// Navigates to the client sign-in page.
/// ///
/// This page allows existing clients to log in using email/password /// This page allows existing clients to log in using email/password

View File

@@ -32,10 +32,17 @@ extension StaffNavigator on IModularNavigator {
/// ///
/// This effectively logs out the user by navigating to root. /// This effectively logs out the user by navigating to root.
/// Used when signing out or session expires. /// Used when signing out or session expires.
void toGetStarted() { void toInitialPage() {
navigate(StaffPaths.root); 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. /// Navigates to the phone verification page.
/// ///
/// Used for both login and signup flows to verify phone numbers via OTP. /// Used for both login and signup flows to verify phone numbers via OTP.

View File

@@ -3,7 +3,6 @@
/// This package provides mock implementations of domain repository interfaces /// This package provides mock implementations of domain repository interfaces
/// for development and testing purposes. /// 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. /// They will implement interfaces defined in feature packages once those are created.
library; library;
@@ -14,6 +13,7 @@ export 'src/session/client_session_store.dart';
// Export the generated Data Connect SDK // Export the generated Data Connect SDK
export 'src/dataconnect_generated/generated.dart'; export 'src/dataconnect_generated/generated.dart';
export 'src/services/data_connect_service.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/session/staff_session_store.dart';
export 'src/mixins/data_error_handler.dart'; export 'src/services/mixins/data_error_handler.dart';

View File

@@ -2,16 +2,18 @@ import 'dart:async';
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth; import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; 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_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../../krow_data_connect.dart' as dc; 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. /// A centralized service for interacting with Firebase Data Connect.
/// ///
/// This service provides common utilities and context management for all repositories. /// This service provides common utilities and context management for all repositories.
class DataConnectService with DataErrorHandler { class DataConnectService with DataErrorHandler, SessionHandlerMixin {
DataConnectService._(); DataConnectService._();
/// The singleton instance of the [DataConnectService]. /// The singleton instance of the [DataConnectService].
@@ -50,8 +52,11 @@ class DataConnectService with DataErrorHandler {
} }
try { try {
final fdc.QueryResult<dc.GetStaffByUserIdData, dc.GetStaffByUserIdVariables> final fdc.QueryResult<
response = await executeProtected( dc.GetStaffByUserIdData,
dc.GetStaffByUserIdVariables
>
response = await executeProtected(
() => connector.getStaffByUserId(userId: user.uid).execute(), () => connector.getStaffByUserId(userId: user.uid).execute(),
); );
@@ -78,7 +83,7 @@ class DataConnectService with DataErrorHandler {
// 2. Check Cache // 2. Check Cache
if (_cachedBusinessId != null) return _cachedBusinessId!; if (_cachedBusinessId != null) return _cachedBusinessId!;
// 3. Check Auth Status // 3. Fetch from Data Connect using Firebase UID
final firebase_auth.User? user = _auth.currentUser; final firebase_auth.User? user = _auth.currentUser;
if (user == null) { if (user == null) {
throw const NotAuthenticatedException( 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) try {
// Ideally we'd have a getBusinessByUserId query here. 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; return user.uid;
} }
@@ -130,13 +151,18 @@ class DataConnectService with DataErrorHandler {
Future<T> run<T>( Future<T> run<T>(
Future<T> Function() action, { Future<T> Function() action, {
bool requiresAuthentication = true, bool requiresAuthentication = true,
}) { }) async {
if (requiresAuthentication && auth.currentUser == null) { if (requiresAuthentication && auth.currentUser == null) {
throw const NotAuthenticatedException( throw const NotAuthenticatedException(
technicalMessage: 'User must be authenticated to perform this action', 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). /// Clears the internal cache (e.g., on logout).
@@ -144,4 +170,28 @@ class DataConnectService with DataErrorHandler {
_cachedStaffId = null; _cachedStaffId = null;
_cachedBusinessId = null; _cachedBusinessId = null;
} }
/// Handle session sign-out by clearing caches.
void handleSignOut() {
clearCache();
}
@override
Future<String?> fetchUserRole(String userId) async {
try {
final fdc.QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables>
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<void> dispose() async {
await disposeSessionHandler();
}
} }

View File

@@ -20,8 +20,12 @@ mixin DataErrorHandler {
try { try {
return await action().timeout(timeout); return await action().timeout(timeout);
} on TimeoutException { } on TimeoutException {
debugPrint(
'DataErrorHandler: Request timed out after ${timeout.inSeconds}s',
);
throw ServiceUnavailableException( throw ServiceUnavailableException(
technicalMessage: 'Request timed out after ${timeout.inSeconds}s'); technicalMessage: 'Request timed out after ${timeout.inSeconds}s',
);
} on SocketException catch (e) { } on SocketException catch (e) {
throw NetworkException(technicalMessage: 'SocketException: ${e.message}'); throw NetworkException(technicalMessage: 'SocketException: ${e.message}');
} on FirebaseException catch (e) { } on FirebaseException catch (e) {
@@ -32,16 +36,26 @@ mixin DataErrorHandler {
msg.contains('offline') || msg.contains('offline') ||
msg.contains('network') || msg.contains('network') ||
msg.contains('connection failed')) { msg.contains('connection failed')) {
debugPrint(
'DataErrorHandler: Firebase network error: ${e.code} - ${e.message}',
);
throw NetworkException( throw NetworkException(
technicalMessage: 'Firebase ${e.code}: ${e.message}'); technicalMessage: 'Firebase ${e.code}: ${e.message}',
);
} }
if (code == 'deadline-exceeded') { if (code == 'deadline-exceeded') {
debugPrint(
'DataErrorHandler: Firebase timeout error: ${e.code} - ${e.message}',
);
throw ServiceUnavailableException( 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 // Fallback for other Firebase errors
throw ServerException( throw ServerException(
technicalMessage: 'Firebase ${e.code}: ${e.message}'); technicalMessage: 'Firebase ${e.code}: ${e.message}',
);
} catch (e) { } catch (e) {
final String errorStr = e.toString().toLowerCase(); final String errorStr = e.toString().toLowerCase();
if (errorStr.contains('socketexception') || if (errorStr.contains('socketexception') ||
@@ -56,15 +70,16 @@ mixin DataErrorHandler {
errorStr.contains('grpc error') || errorStr.contains('grpc error') ||
errorStr.contains('terminated') || errorStr.contains('terminated') ||
errorStr.contains('connectexception')) { errorStr.contains('connectexception')) {
debugPrint('DataErrorHandler: Network-related error: $e');
throw NetworkException(technicalMessage: e.toString()); throw NetworkException(technicalMessage: e.toString());
} }
// If it's already an AppException, rethrow it // If it's already an AppException, rethrow it
if (e is AppException) rethrow; if (e is AppException) rethrow;
// Debugging: Log unexpected errors // Debugging: Log unexpected errors
debugPrint('DataErrorHandler: Unhandled exception caught: $e'); debugPrint('DataErrorHandler: Unhandled exception caught: $e');
throw UnknownException(technicalMessage: e.toString()); throw UnknownException(technicalMessage: e.toString());
} }
} }

View File

@@ -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<SessionState> _sessionStateController =
StreamController<SessionState>.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<SessionState> 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<SessionState> _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<firebase_auth.User?>? _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<String> _allowedRoles = <String>[];
/// Initialize the auth state listener (call once on app startup).
void initializeAuthListener({List<String> allowedRoles = const <String>[]}) {
_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<bool> validateUserRole(
String userId,
List<String> 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<String?> 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<void> 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<void>.delayed(backoffDuration);
}
}
}
/// Handle user sign-in event.
Future<void> _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<void> disposeSessionHandler() async {
await _authStateSubscription?.cancel();
await _sessionStateController.close();
}
}

View File

@@ -1,5 +1,3 @@
import 'package:krow_domain/krow_domain.dart' as domain;
class ClientBusinessSession { class ClientBusinessSession {
final String id; final String id;
final String businessName; final String businessName;
@@ -19,15 +17,9 @@ class ClientBusinessSession {
} }
class ClientSession { class ClientSession {
final domain.User user;
final String? userPhotoUrl;
final ClientBusinessSession? business; final ClientBusinessSession? business;
const ClientSession({ const ClientSession({required this.business});
required this.user,
required this.userPhotoUrl,
required this.business,
});
} }
class ClientSessionStore { class ClientSessionStore {

View File

@@ -1,18 +1,15 @@
import 'package:krow_domain/krow_domain.dart' as domain; import 'package:krow_domain/krow_domain.dart' as domain;
class StaffSession { class StaffSession {
const StaffSession({required this.user, this.staff, this.ownerId});
final domain.User user; final domain.User user;
final domain.Staff? staff; final domain.Staff? staff;
final String? ownerId; final String? ownerId;
const StaffSession({
required this.user,
this.staff,
this.ownerId,
});
} }
class StaffSessionStore { class StaffSessionStore {
StaffSessionStore._();
StaffSession? _session; StaffSession? _session;
StaffSession? get session => _session; StaffSession? get session => _session;
@@ -26,6 +23,4 @@ class StaffSessionStore {
} }
static final StaffSessionStore instance = StaffSessionStore._(); static final StaffSessionStore instance = StaffSessionStore._();
StaffSessionStore._();
} }

View File

@@ -53,6 +53,10 @@ export 'src/entities/financial/invoice_item.dart';
export 'src/entities/financial/invoice_decline.dart'; export 'src/entities/financial/invoice_decline.dart';
export 'src/entities/financial/staff_payment.dart'; export 'src/entities/financial/staff_payment.dart';
export 'src/entities/financial/payment_summary.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 // Profile
export 'src/entities/profile/staff_document.dart'; export 'src/entities/profile/staff_document.dart';
@@ -68,7 +72,6 @@ export 'src/entities/ratings/business_staff_preference.dart';
// Staff Profile // Staff Profile
export 'src/entities/profile/emergency_contact.dart'; export 'src/entities/profile/emergency_contact.dart';
export 'src/entities/profile/bank_account.dart';
export 'src/entities/profile/accessibility.dart'; export 'src/entities/profile/accessibility.dart';
export 'src/entities/profile/schedule.dart'; export 'src/entities/profile/schedule.dart';

View File

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

View File

@@ -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 { class BankAccountAdapter {
/// Maps primitive values to [BankAccount]. /// Maps primitive values to [StaffBankAccount].
static BankAccount fromPrimitives({ static StaffBankAccount fromPrimitives({
required String id, required String id,
required String userId, required String userId,
required String bankName, required String bankName,
@@ -13,7 +13,7 @@ class BankAccountAdapter {
String? sortCode, String? sortCode,
bool? isPrimary, bool? isPrimary,
}) { }) {
return BankAccount( return StaffBankAccount(
id: id, id: id,
userId: userId, userId: userId,
bankName: bankName, bankName: bankName,
@@ -26,25 +26,25 @@ class BankAccountAdapter {
); );
} }
static BankAccountType _stringToType(String? value) { static StaffBankAccountType _stringToType(String? value) {
if (value == null) return BankAccountType.checking; if (value == null) return StaffBankAccountType.checking;
try { try {
// Assuming backend enum names match or are uppercase // Assuming backend enum names match or are uppercase
return BankAccountType.values.firstWhere( return StaffBankAccountType.values.firstWhere(
(e) => e.name.toLowerCase() == value.toLowerCase(), (StaffBankAccountType e) => e.name.toLowerCase() == value.toLowerCase(),
orElse: () => BankAccountType.other, orElse: () => StaffBankAccountType.other,
); );
} catch (_) { } catch (_) {
return BankAccountType.other; return StaffBankAccountType.other;
} }
} }
/// Converts domain type to string for backend. /// Converts domain type to string for backend.
static String typeToString(BankAccountType type) { static String typeToString(StaffBankAccountType type) {
switch (type) { switch (type) {
case BankAccountType.checking: case StaffBankAccountType.checking:
return 'CHECKING'; return 'CHECKING';
case BankAccountType.savings: case StaffBankAccountType.savings:
return 'SAVINGS'; return 'SAVINGS';
default: default:
return 'CHECKING'; return 'CHECKING';

View File

@@ -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<Object?> get props => <Object?>[id, bankName, isPrimary, last4];
}

View File

@@ -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<Object?> get props => <Object?>[
...super.props,
expiryTime,
];
/// Getter for non-nullable last4 in Business context.
@override
String get last4 => super.last4!;
}

View File

@@ -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<Object?> get props =>
<Object?>[...super.props, userId, accountNumber, accountName, sortCode, type];
}

View File

@@ -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<Object?> get props => <Object?>[id, userId, bankName, accountNumber, accountName, sortCode, type, isPrimary, last4];
}

View File

@@ -24,9 +24,8 @@ import '../../domain/repositories/auth_repository_interface.dart';
/// identity management and Krow's Data Connect SDK for storing user profile data. /// identity management and Krow's Data Connect SDK for storing user profile data.
class AuthRepositoryImpl implements AuthRepositoryInterface { class AuthRepositoryImpl implements AuthRepositoryInterface {
/// Creates an [AuthRepositoryImpl] with the real dependencies. /// Creates an [AuthRepositoryImpl] with the real dependencies.
AuthRepositoryImpl({ AuthRepositoryImpl({dc.DataConnectService? service})
dc.DataConnectService? service, : _service = service ?? dc.DataConnectService.instance;
}) : _service = service ?? dc.DataConnectService.instance;
final dc.DataConnectService _service; final dc.DataConnectService _service;
@@ -36,11 +35,8 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
required String password, required String password,
}) async { }) async {
try { try {
final firebase.UserCredential credential = final firebase.UserCredential credential = await _service.auth
await _service.auth.signInWithEmailAndPassword( .signInWithEmailAndPassword(email: email, password: password);
email: email,
password: password,
);
final firebase.User? firebaseUser = credential.user; final firebase.User? firebaseUser = credential.user;
if (firebaseUser == null) { if (firebaseUser == null) {
@@ -60,9 +56,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
technicalMessage: 'Firebase error code: ${e.code}', technicalMessage: 'Firebase error code: ${e.code}',
); );
} else if (e.code == 'network-request-failed') { } else if (e.code == 'network-request-failed') {
throw NetworkException( throw NetworkException(technicalMessage: 'Firebase: ${e.message}');
technicalMessage: 'Firebase: ${e.message}',
);
} else { } else {
throw SignInFailedException( throw SignInFailedException(
technicalMessage: 'Firebase auth error: ${e.message}', technicalMessage: 'Firebase auth error: ${e.message}',
@@ -71,9 +65,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
} on domain.AppException { } on domain.AppException {
rethrow; rethrow;
} catch (e) { } catch (e) {
throw SignInFailedException( throw SignInFailedException(technicalMessage: 'Unexpected error: $e');
technicalMessage: 'Unexpected error: $e',
);
} }
} }
@@ -88,11 +80,8 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
try { try {
// Step 1: Try to create Firebase Auth user // Step 1: Try to create Firebase Auth user
final firebase.UserCredential credential = final firebase.UserCredential credential = await _service.auth
await _service.auth.createUserWithEmailAndPassword( .createUserWithEmailAndPassword(email: email, password: password);
email: email,
password: password,
);
firebaseUser = credential.user; firebaseUser = credential.user;
if (firebaseUser == null) { if (firebaseUser == null) {
@@ -111,9 +100,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
); );
} on firebase.FirebaseAuthException catch (e) { } on firebase.FirebaseAuthException catch (e) {
if (e.code == 'weak-password') { if (e.code == 'weak-password') {
throw WeakPasswordException( throw WeakPasswordException(technicalMessage: 'Firebase: ${e.message}');
technicalMessage: 'Firebase: ${e.message}',
);
} else if (e.code == 'email-already-in-use') { } else if (e.code == 'email-already-in-use') {
// Email exists in Firebase Auth - try to sign in and complete registration // Email exists in Firebase Auth - try to sign in and complete registration
return await _handleExistingFirebaseAccount( return await _handleExistingFirebaseAccount(
@@ -122,9 +109,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
companyName: companyName, companyName: companyName,
); );
} else if (e.code == 'network-request-failed') { } else if (e.code == 'network-request-failed') {
throw NetworkException( throw NetworkException(technicalMessage: 'Firebase: ${e.message}');
technicalMessage: 'Firebase: ${e.message}',
);
} else { } else {
throw SignUpFailedException( throw SignUpFailedException(
technicalMessage: 'Firebase auth error: ${e.message}', technicalMessage: 'Firebase auth error: ${e.message}',
@@ -133,15 +118,17 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
} on domain.AppException { } on domain.AppException {
// Rollback for our known exceptions // Rollback for our known exceptions
await _rollbackSignUp( await _rollbackSignUp(
firebaseUser: firebaseUser, businessId: createdBusinessId); firebaseUser: firebaseUser,
businessId: createdBusinessId,
);
rethrow; rethrow;
} catch (e) { } catch (e) {
// Rollback: Clean up any partially created resources // Rollback: Clean up any partially created resources
await _rollbackSignUp( await _rollbackSignUp(
firebaseUser: firebaseUser, businessId: createdBusinessId); firebaseUser: firebaseUser,
throw SignUpFailedException( businessId: createdBusinessId,
technicalMessage: 'Unexpected error: $e',
); );
throw SignUpFailedException(technicalMessage: 'Unexpected error: $e');
} }
} }
@@ -161,16 +148,15 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
required String password, required String password,
required String companyName, required String companyName,
}) async { }) async {
developer.log('Email exists in Firebase, attempting sign-in: $email', developer.log(
name: 'AuthRepository'); 'Email exists in Firebase, attempting sign-in: $email',
name: 'AuthRepository',
);
try { try {
// Try to sign in with the provided password // Try to sign in with the provided password
final firebase.UserCredential credential = final firebase.UserCredential credential = await _service.auth
await _service.auth.signInWithEmailAndPassword( .signInWithEmailAndPassword(email: email, password: password);
email: email,
password: password,
);
final firebase.User? firebaseUser = credential.user; final firebase.User? firebaseUser = credential.user;
if (firebaseUser == null) { if (firebaseUser == null) {
@@ -180,32 +166,40 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
} }
// Sign-in succeeded! Check if user already has a BUSINESS account in PostgreSQL // Sign-in succeeded! Check if user already has a BUSINESS account in PostgreSQL
final bool hasBusinessAccount = final bool hasBusinessAccount = await _checkBusinessUserExists(
await _checkBusinessUserExists(firebaseUser.uid); firebaseUser.uid,
);
if (hasBusinessAccount) { if (hasBusinessAccount) {
// User already has a KROW Client account // User already has a KROW Client account
developer.log('User already has BUSINESS account: ${firebaseUser.uid}', developer.log(
name: 'AuthRepository'); 'User already has BUSINESS account: ${firebaseUser.uid}',
name: 'AuthRepository',
);
throw AccountExistsException( 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 // User exists in Firebase but not in KROW PostgreSQL - create the entities
developer.log( developer.log(
'Creating BUSINESS account for existing Firebase user: ${firebaseUser.uid}', 'Creating BUSINESS account for existing Firebase user: ${firebaseUser.uid}',
name: 'AuthRepository'); name: 'AuthRepository',
);
return await _createBusinessAndUser( return await _createBusinessAndUser(
firebaseUser: firebaseUser, firebaseUser: firebaseUser,
companyName: companyName, companyName: companyName,
email: email, email: email,
onBusinessCreated: (_) {}, // No rollback needed for existing Firebase user onBusinessCreated:
(_) {}, // No rollback needed for existing Firebase user
); );
} on firebase.FirebaseAuthException catch (e) { } on firebase.FirebaseAuthException catch (e) {
// Sign-in failed - check why // Sign-in failed - check why
developer.log('Sign-in failed with code: ${e.code}', developer.log(
name: 'AuthRepository'); 'Sign-in failed with code: ${e.code}',
name: 'AuthRepository',
);
if (e.code == 'wrong-password' || e.code == 'invalid-credential') { if (e.code == 'wrong-password' || e.code == 'invalid-credential') {
// Password doesn't match - check what providers are available // 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" // We can't distinguish between "wrong password" and "no password provider"
// due to Firebase deprecating fetchSignInMethodsForEmail. // due to Firebase deprecating fetchSignInMethodsForEmail.
// The PasswordMismatchException message covers both scenarios. // The PasswordMismatchException message covers both scenarios.
developer.log('Password mismatch or different provider for: $email', developer.log(
name: 'AuthRepository'); 'Password mismatch or different provider for: $email',
name: 'AuthRepository',
);
throw PasswordMismatchException( throw PasswordMismatchException(
technicalMessage: technicalMessage:
'Email $email: password mismatch or different auth provider', 'Email $email: password mismatch or different auth provider',
@@ -242,7 +238,8 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
Future<bool> _checkBusinessUserExists(String firebaseUserId) async { Future<bool> _checkBusinessUserExists(String firebaseUserId) async {
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response = final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response =
await _service.run( await _service.run(
() => _service.connector.getUserById(id: firebaseUserId).execute()); () => _service.connector.getUserById(id: firebaseUserId).execute(),
);
final dc.GetUserByIdUser? user = response.data.user; final dc.GetUserByIdUser? user = response.data.user;
return user != null && return user != null &&
(user.userRole == 'BUSINESS' || user.userRole == 'BOTH'); (user.userRole == 'BUSINESS' || user.userRole == 'BOTH');
@@ -258,14 +255,16 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
// Create Business entity in PostgreSQL // Create Business entity in PostgreSQL
final OperationResult<dc.CreateBusinessData, dc.CreateBusinessVariables> final OperationResult<dc.CreateBusinessData, dc.CreateBusinessVariables>
createBusinessResponse = await _service.run(() => _service.connector createBusinessResponse = await _service.run(
.createBusiness( () => _service.connector
businessName: companyName, .createBusiness(
userId: firebaseUser.uid, businessName: companyName,
rateGroup: dc.BusinessRateGroup.STANDARD, userId: firebaseUser.uid,
status: dc.BusinessStatus.PENDING, rateGroup: dc.BusinessRateGroup.STANDARD,
) status: dc.BusinessStatus.PENDING,
.execute()); )
.execute(),
);
final dc.CreateBusinessBusinessInsert businessData = final dc.CreateBusinessBusinessInsert businessData =
createBusinessResponse.data.business_insert; createBusinessResponse.data.business_insert;
@@ -273,28 +272,28 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
// Check if User entity already exists in PostgreSQL // Check if User entity already exists in PostgreSQL
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> userResult = final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> userResult =
await _service.run(() => await _service.run(
_service.connector.getUserById(id: firebaseUser.uid).execute()); () => _service.connector.getUserById(id: firebaseUser.uid).execute(),
);
final dc.GetUserByIdUser? existingUser = userResult.data.user; final dc.GetUserByIdUser? existingUser = userResult.data.user;
if (existingUser != null) { if (existingUser != null) {
// User exists (likely in another app like STAFF). Update role to BOTH. // User exists (likely in another app like STAFF). Update role to BOTH.
await _service.run(() => _service.connector await _service.run(
.updateUser( () => _service.connector
id: firebaseUser.uid, .updateUser(id: firebaseUser.uid)
) .userRole('BOTH')
.userRole('BOTH') .execute(),
.execute()); );
} else { } else {
// Create new User entity in PostgreSQL // Create new User entity in PostgreSQL
await _service.run(() => _service.connector await _service.run(
.createUser( () => _service.connector
id: firebaseUser.uid, .createUser(id: firebaseUser.uid, role: dc.UserBaseRole.USER)
role: dc.UserBaseRole.USER, .email(email)
) .userRole('BUSINESS')
.email(email) .execute(),
.userRole('BUSINESS') );
.execute());
} }
return _getUserProfile( return _getUserProfile(
@@ -340,7 +339,8 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
@override @override
Future<domain.User> signInWithSocial({required String provider}) { Future<domain.User> signInWithSocial({required String provider}) {
throw UnimplementedError( throw UnimplementedError(
'Social authentication with $provider is not yet implemented.'); 'Social authentication with $provider is not yet implemented.',
);
} }
Future<domain.User> _getUserProfile({ Future<domain.User> _getUserProfile({
@@ -349,8 +349,9 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
bool requireBusinessRole = false, bool requireBusinessRole = false,
}) async { }) async {
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response = final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response =
await _service.run(() => await _service.run(
_service.connector.getUserById(id: firebaseUserId).execute()); () => _service.connector.getUserById(id: firebaseUserId).execute(),
);
final dc.GetUserByIdUser? user = response.data.user; final dc.GetUserByIdUser? user = response.data.user;
if (user == null) { if (user == null) {
throw UserNotFoundException( throw UserNotFoundException(
@@ -383,22 +384,22 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
role: user.role.stringValue, role: user.role.stringValue,
); );
final QueryResult<dc.GetBusinessesByUserIdData, final QueryResult<
dc.GetBusinessesByUserIdVariables> businessResponse = dc.GetBusinessesByUserIdData,
await _service.run(() => _service.connector dc.GetBusinessesByUserIdVariables
.getBusinessesByUserId( >
userId: firebaseUserId, businessResponse = await _service.run(
) () => _service.connector
.execute()); .getBusinessesByUserId(userId: firebaseUserId)
.execute(),
);
final dc.GetBusinessesByUserIdBusinesses? business = final dc.GetBusinessesByUserIdBusinesses? business =
businessResponse.data.businesses.isNotEmpty businessResponse.data.businesses.isNotEmpty
? businessResponse.data.businesses.first ? businessResponse.data.businesses.first
: null; : null;
dc.ClientSessionStore.instance.setSession( dc.ClientSessionStore.instance.setSession(
dc.ClientSession( dc.ClientSession(
user: domainUser,
userPhotoUrl: user.photoUrl,
business: business == null business: business == null
? null ? null
: dc.ClientBusinessSession( : dc.ClientBusinessSession(
@@ -414,26 +415,4 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
return domainUser; return domainUser;
} }
@override
Future<domain.User?> 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;
}
}
} }

View File

@@ -34,7 +34,4 @@ abstract class AuthRepositoryInterface {
/// Terminates the current user session and clears authentication tokens. /// Terminates the current user session and clears authentication tokens.
Future<void> signOut(); Future<void> signOut();
/// Restores the session if a user is already logged in.
Future<User?> restoreSession();
} }

View File

@@ -1,60 +1,16 @@
import 'dart:async'; import 'package:design_system/design_system.dart';
import 'package:client_authentication/src/domain/repositories/auth_repository_interface.dart';
import 'package:flutter/material.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}); const ClientIntroPage({super.key});
@override
State<ClientIntroPage> createState() => _ClientIntroPageState();
}
class _ClientIntroPageState extends State<ClientIntroPage> {
@override
void initState() {
super.initState();
_checkSession();
}
Future<void> _checkSession() async {
// Check session immediately without artificial delay
if (!mounted) return;
try {
final AuthRepositoryInterface authRepo = Modular.get<AuthRepositoryInterface>();
// 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, backgroundColor: Theme.of(context).colorScheme.surface,
body: Center( body: Center(
child: Image.asset( child: Image.asset(
'assets/logo-blue.png', UiImageAssets.logoBlue,
package: 'design_system',
width: 120, width: 120,
), ),
), ),

View File

@@ -3,6 +3,7 @@ import 'package:krow_core/core.dart';
import 'data/repositories_impl/billing_repository_impl.dart'; import 'data/repositories_impl/billing_repository_impl.dart';
import 'domain/repositories/billing_repository.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_current_bill_amount.dart';
import 'domain/usecases/get_invoice_history.dart'; import 'domain/usecases/get_invoice_history.dart';
import 'domain/usecases/get_pending_invoices.dart'; import 'domain/usecases/get_pending_invoices.dart';
@@ -21,6 +22,7 @@ class BillingModule extends Module {
i.addSingleton<BillingRepository>(BillingRepositoryImpl.new); i.addSingleton<BillingRepository>(BillingRepositoryImpl.new);
// Use Cases // Use Cases
i.addSingleton(GetBankAccountsUseCase.new);
i.addSingleton(GetCurrentBillAmountUseCase.new); i.addSingleton(GetCurrentBillAmountUseCase.new);
i.addSingleton(GetSavingsAmountUseCase.new); i.addSingleton(GetSavingsAmountUseCase.new);
i.addSingleton(GetPendingInvoicesUseCase.new); i.addSingleton(GetPendingInvoicesUseCase.new);
@@ -30,6 +32,7 @@ class BillingModule extends Module {
// BLoCs // BLoCs
i.addSingleton<BillingBloc>( i.addSingleton<BillingBloc>(
() => BillingBloc( () => BillingBloc(
getBankAccounts: i.get<GetBankAccountsUseCase>(),
getCurrentBillAmount: i.get<GetCurrentBillAmountUseCase>(), getCurrentBillAmount: i.get<GetCurrentBillAmountUseCase>(),
getSavingsAmount: i.get<GetSavingsAmountUseCase>(), getSavingsAmount: i.get<GetSavingsAmountUseCase>(),
getPendingInvoices: i.get<GetPendingInvoicesUseCase>(), getPendingInvoices: i.get<GetPendingInvoicesUseCase>(),

View File

@@ -16,6 +16,23 @@ class BillingRepositoryImpl implements BillingRepository {
final data_connect.DataConnectService _service; final data_connect.DataConnectService _service;
/// Fetches bank accounts associated with the business.
@override
Future<List<BusinessBankAccount>> 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. /// Fetches the current bill amount by aggregating open invoices.
@override @override
Future<double> getCurrentBillAmount() async { Future<double> 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( InvoiceStatus _mapInvoiceStatus(
data_connect.EnumValue<data_connect.InvoiceStatus> status, data_connect.EnumValue<data_connect.InvoiceStatus> status,
) { ) {

View File

@@ -7,6 +7,9 @@ import '../models/billing_period.dart';
/// acting as a boundary between the Domain and Data layers. /// acting as a boundary between the Domain and Data layers.
/// It allows the Domain layer to remain independent of specific data sources. /// It allows the Domain layer to remain independent of specific data sources.
abstract class BillingRepository { abstract class BillingRepository {
/// Fetches bank accounts associated with the business.
Future<List<BusinessBankAccount>> getBankAccounts();
/// Fetches invoices that are pending approval or payment. /// Fetches invoices that are pending approval or payment.
Future<List<Invoice>> getPendingInvoices(); Future<List<Invoice>> getPendingInvoices();

View File

@@ -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<List<BusinessBankAccount>> {
/// Creates a [GetBankAccountsUseCase].
GetBankAccountsUseCase(this._repository);
final BillingRepository _repository;
@override
Future<List<BusinessBankAccount>> call() => _repository.getBankAccounts();
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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_current_bill_amount.dart';
import '../../domain/usecases/get_invoice_history.dart'; import '../../domain/usecases/get_invoice_history.dart';
import '../../domain/usecases/get_pending_invoices.dart'; import '../../domain/usecases/get_pending_invoices.dart';
@@ -16,12 +17,14 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
with BlocErrorHandler<BillingState> { with BlocErrorHandler<BillingState> {
/// Creates a [BillingBloc] with the given use cases. /// Creates a [BillingBloc] with the given use cases.
BillingBloc({ BillingBloc({
required GetBankAccountsUseCase getBankAccounts,
required GetCurrentBillAmountUseCase getCurrentBillAmount, required GetCurrentBillAmountUseCase getCurrentBillAmount,
required GetSavingsAmountUseCase getSavingsAmount, required GetSavingsAmountUseCase getSavingsAmount,
required GetPendingInvoicesUseCase getPendingInvoices, required GetPendingInvoicesUseCase getPendingInvoices,
required GetInvoiceHistoryUseCase getInvoiceHistory, required GetInvoiceHistoryUseCase getInvoiceHistory,
required GetSpendingBreakdownUseCase getSpendingBreakdown, required GetSpendingBreakdownUseCase getSpendingBreakdown,
}) : _getCurrentBillAmount = getCurrentBillAmount, }) : _getBankAccounts = getBankAccounts,
_getCurrentBillAmount = getCurrentBillAmount,
_getSavingsAmount = getSavingsAmount, _getSavingsAmount = getSavingsAmount,
_getPendingInvoices = getPendingInvoices, _getPendingInvoices = getPendingInvoices,
_getInvoiceHistory = getInvoiceHistory, _getInvoiceHistory = getInvoiceHistory,
@@ -31,6 +34,7 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
on<BillingPeriodChanged>(_onPeriodChanged); on<BillingPeriodChanged>(_onPeriodChanged);
} }
final GetBankAccountsUseCase _getBankAccounts;
final GetCurrentBillAmountUseCase _getCurrentBillAmount; final GetCurrentBillAmountUseCase _getCurrentBillAmount;
final GetSavingsAmountUseCase _getSavingsAmount; final GetSavingsAmountUseCase _getSavingsAmount;
final GetPendingInvoicesUseCase _getPendingInvoices; final GetPendingInvoicesUseCase _getPendingInvoices;
@@ -52,12 +56,15 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
_getPendingInvoices.call(), _getPendingInvoices.call(),
_getInvoiceHistory.call(), _getInvoiceHistory.call(),
_getSpendingBreakdown.call(state.period), _getSpendingBreakdown.call(state.period),
_getBankAccounts.call(),
]); ]);
final double savings = results[1] as double; final double savings = results[1] as double;
final List<Invoice> pendingInvoices = results[2] as List<Invoice>; final List<Invoice> pendingInvoices = results[2] as List<Invoice>;
final List<Invoice> invoiceHistory = results[3] as List<Invoice>; final List<Invoice> invoiceHistory = results[3] as List<Invoice>;
final List<InvoiceItem> spendingItems = results[4] as List<InvoiceItem>; final List<InvoiceItem> spendingItems = results[4] as List<InvoiceItem>;
final List<BusinessBankAccount> bankAccounts =
results[5] as List<BusinessBankAccount>;
// Map Domain Entities to Presentation Models // Map Domain Entities to Presentation Models
final List<BillingInvoice> uiPendingInvoices = final List<BillingInvoice> uiPendingInvoices =
@@ -79,6 +86,7 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
pendingInvoices: uiPendingInvoices, pendingInvoices: uiPendingInvoices,
invoiceHistory: uiInvoiceHistory, invoiceHistory: uiInvoiceHistory,
spendingBreakdown: uiSpendingBreakdown, spendingBreakdown: uiSpendingBreakdown,
bankAccounts: bankAccounts,
), ),
); );
}, },

View File

@@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/models/billing_period.dart'; import '../../domain/models/billing_period.dart';
import '../models/billing_invoice_model.dart'; import '../models/billing_invoice_model.dart';
import '../models/spending_breakdown_model.dart'; import '../models/spending_breakdown_model.dart';
@@ -28,6 +29,7 @@ class BillingState extends Equatable {
this.pendingInvoices = const <BillingInvoice>[], this.pendingInvoices = const <BillingInvoice>[],
this.invoiceHistory = const <BillingInvoice>[], this.invoiceHistory = const <BillingInvoice>[],
this.spendingBreakdown = const <SpendingBreakdownItem>[], this.spendingBreakdown = const <SpendingBreakdownItem>[],
this.bankAccounts = const <BusinessBankAccount>[],
this.period = BillingPeriod.week, this.period = BillingPeriod.week,
this.errorMessage, this.errorMessage,
}); });
@@ -50,6 +52,9 @@ class BillingState extends Equatable {
/// Breakdown of spending by category. /// Breakdown of spending by category.
final List<SpendingBreakdownItem> spendingBreakdown; final List<SpendingBreakdownItem> spendingBreakdown;
/// Bank accounts associated with the business.
final List<BusinessBankAccount> bankAccounts;
/// Selected period for the breakdown. /// Selected period for the breakdown.
final BillingPeriod period; final BillingPeriod period;
@@ -64,6 +69,7 @@ class BillingState extends Equatable {
List<BillingInvoice>? pendingInvoices, List<BillingInvoice>? pendingInvoices,
List<BillingInvoice>? invoiceHistory, List<BillingInvoice>? invoiceHistory,
List<SpendingBreakdownItem>? spendingBreakdown, List<SpendingBreakdownItem>? spendingBreakdown,
List<BusinessBankAccount>? bankAccounts,
BillingPeriod? period, BillingPeriod? period,
String? errorMessage, String? errorMessage,
}) { }) {
@@ -74,6 +80,7 @@ class BillingState extends Equatable {
pendingInvoices: pendingInvoices ?? this.pendingInvoices, pendingInvoices: pendingInvoices ?? this.pendingInvoices,
invoiceHistory: invoiceHistory ?? this.invoiceHistory, invoiceHistory: invoiceHistory ?? this.invoiceHistory,
spendingBreakdown: spendingBreakdown ?? this.spendingBreakdown, spendingBreakdown: spendingBreakdown ?? this.spendingBreakdown,
bankAccounts: bankAccounts ?? this.bankAccounts,
period: period ?? this.period, period: period ?? this.period,
errorMessage: errorMessage ?? this.errorMessage, errorMessage: errorMessage ?? this.errorMessage,
); );
@@ -87,6 +94,7 @@ class BillingState extends Equatable {
pendingInvoices, pendingInvoices,
invoiceHistory, invoiceHistory,
spendingBreakdown, spendingBreakdown,
bankAccounts,
period, period,
errorMessage, errorMessage,
]; ];

View File

@@ -71,19 +71,20 @@ class _BillingViewState extends State<BillingView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocConsumer<BillingBloc, BillingState>( return Scaffold(
listener: (BuildContext context, BillingState state) { body: BlocConsumer<BillingBloc, BillingState>(
if (state.status == BillingStatus.failure && state.errorMessage != null) { listener: (BuildContext context, BillingState state) {
UiSnackbar.show( if (state.status == BillingStatus.failure &&
context, state.errorMessage != null) {
message: translateErrorKey(state.errorMessage!), UiSnackbar.show(
type: UiSnackbarType.error, context,
); message: translateErrorKey(state.errorMessage!),
} type: UiSnackbarType.error,
}, );
builder: (BuildContext context, BillingState state) { }
return Scaffold( },
body: CustomScrollView( builder: (BuildContext context, BillingState state) {
return CustomScrollView(
controller: _scrollController, controller: _scrollController,
slivers: <Widget>[ slivers: <Widget>[
SliverAppBar( SliverAppBar(
@@ -97,7 +98,7 @@ class _BillingViewState extends State<BillingView> {
leading: Center( leading: Center(
child: UiIconButton.secondary( child: UiIconButton.secondary(
icon: UiIcons.arrowLeft, icon: UiIcons.arrowLeft,
onTap: () => Modular.to.toClientHome() onTap: () => Modular.to.toClientHome(),
), ),
), ),
title: AnimatedSwitcher( title: AnimatedSwitcher(
@@ -132,8 +133,9 @@ class _BillingViewState extends State<BillingView> {
const SizedBox(height: UiConstants.space1), const SizedBox(height: UiConstants.space1),
Text( Text(
'\$${state.currentBill.toStringAsFixed(2)}', '\$${state.currentBill.toStringAsFixed(2)}',
style: UiTypography.display1b style: UiTypography.display1b.copyWith(
.copyWith(color: UiColors.white), color: UiColors.white,
),
), ),
const SizedBox(height: UiConstants.space2), const SizedBox(height: UiConstants.space2),
Container( Container(
@@ -171,16 +173,14 @@ class _BillingViewState extends State<BillingView> {
), ),
), ),
SliverList( SliverList(
delegate: SliverChildListDelegate( delegate: SliverChildListDelegate(<Widget>[
<Widget>[ _buildContent(context, state),
_buildContent(context, state), ]),
],
),
), ),
], ],
), );
); },
}, ),
); );
} }
@@ -211,7 +211,9 @@ class _BillingViewState extends State<BillingView> {
const SizedBox(height: UiConstants.space4), const SizedBox(height: UiConstants.space4),
UiButton.secondary( UiButton.secondary(
text: 'Retry', text: 'Retry',
onPressed: () => BlocProvider.of<BillingBloc>(context).add(const BillingLoadStarted()), onPressed: () => BlocProvider.of<BillingBloc>(
context,
).add(const BillingLoadStarted()),
), ),
], ],
), ),
@@ -230,8 +232,10 @@ class _BillingViewState extends State<BillingView> {
], ],
const PaymentMethodCard(), const PaymentMethodCard(),
const SpendingBreakdownCard(), const SpendingBreakdownCard(),
if (state.invoiceHistory.isEmpty) _buildEmptyState(context) if (state.invoiceHistory.isEmpty)
else InvoiceHistorySection(invoices: state.invoiceHistory), _buildEmptyState(context)
else
InvoiceHistorySection(invoices: state.invoiceHistory),
const SizedBox(height: UiConstants.space32), const SizedBox(height: UiConstants.space32),
], ],

View File

@@ -1,166 +1,133 @@
import 'package:core_localization/core_localization.dart'; import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.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: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. /// Card showing the current payment method.
class PaymentMethodCard extends StatefulWidget { class PaymentMethodCard extends StatelessWidget {
/// Creates a [PaymentMethodCard]. /// Creates a [PaymentMethodCard].
const PaymentMethodCard({super.key}); const PaymentMethodCard({super.key});
@override
State<PaymentMethodCard> createState() => _PaymentMethodCardState();
}
class _PaymentMethodCardState extends State<PaymentMethodCard> {
late final Future<dc.GetAccountsByOwnerIdData?> _accountsFuture =
_loadAccounts();
Future<dc.GetAccountsByOwnerIdData?> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FutureBuilder<dc.GetAccountsByOwnerIdData?>( return BlocBuilder<BillingBloc, BillingState>(
future: _accountsFuture, builder: (BuildContext context, BillingState state) {
builder: final List<BusinessBankAccount> accounts = state.bankAccounts;
( final BusinessBankAccount? account =
BuildContext context, accounts.isNotEmpty ? accounts.first : null;
AsyncSnapshot<dc.GetAccountsByOwnerIdData?> snapshot,
) {
final List<dc.GetAccountsByOwnerIdAccounts> accounts =
snapshot.data?.accounts ?? <dc.GetAccountsByOwnerIdAccounts>[];
final dc.GetAccountsByOwnerIdAccounts? account = accounts.isNotEmpty
? accounts.first
: null;
if (account == null) { if (account == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final String bankLabel = account.bank.isNotEmpty == true final String bankLabel =
? account.bank account.bankName.isNotEmpty == true ? account.bankName : '----';
: '----'; final String last4 =
final String last4 = account.last4.isNotEmpty == true account.last4.isNotEmpty == true ? account.last4 : '----';
? account.last4 final bool isPrimary = account.isPrimary;
: '----'; final String expiryLabel = _formatExpiry(account.expiryTime);
final bool isPrimary = account.isPrimary ?? false;
final String expiryLabel = _formatExpiry(account.expiryTime);
return Container( return Container(
padding: const EdgeInsets.all(UiConstants.space4), padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UiColors.white, color: UiColors.white,
borderRadius: UiConstants.radiusLg, borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border), border: Border.all(color: UiColors.border),
boxShadow: <BoxShadow>[ boxShadow: <BoxShadow>[
BoxShadow( BoxShadow(
color: UiColors.black.withValues(alpha: 0.04), color: UiColors.black.withValues(alpha: 0.04),
blurRadius: 8, blurRadius: 8,
offset: const Offset(0, 2), offset: const Offset(0, 2),
),
],
), ),
child: Column( ],
),
child: Column(
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[ children: <Widget>[
Row( Text(
mainAxisAlignment: MainAxisAlignment.spaceBetween, t.client_billing.payment_method,
children: <Widget>[ style: UiTypography.title2b.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: <Widget>[
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: <Widget>[
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,
),
),
],
),
), ),
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: <Widget>[
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: <Widget>[
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) { if (expiryTime == null) {
return 'N/A'; return 'N/A';
} }
final DateTime date = expiryTime.toDateTime(); final String month = expiryTime.month.toString().padLeft(2, '0');
final String month = date.month.toString().padLeft(2, '0'); final String year = (expiryTime.year % 100).toString().padLeft(2, '0');
final String year = (date.year % 100).toString().padLeft(2, '0');
return '$month/$year'; return '$month/$year';
} }
} }

View File

@@ -19,26 +19,43 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
final DateTime now = DateTime.now(); final DateTime now = DateTime.now();
final int daysFromMonday = now.weekday - DateTime.monday; final int daysFromMonday = now.weekday - DateTime.monday;
final DateTime monday = final DateTime monday = DateTime(
DateTime(now.year, now.month, now.day).subtract(Duration(days: daysFromMonday)); now.year,
final DateTime weekRangeStart = DateTime(monday.year, monday.month, monday.day); now.month,
final DateTime weekRangeEnd = now.day,
DateTime(monday.year, monday.month, monday.day + 13, 23, 59, 59, 999); ).subtract(Duration(days: daysFromMonday));
final fdc.QueryResult<dc.GetCompletedShiftsByBusinessIdData, final DateTime weekRangeStart = DateTime(
dc.GetCompletedShiftsByBusinessIdVariables> completedResult = monday.year,
await _service.connector monday.month,
.getCompletedShiftsByBusinessId( monday.day,
businessId: businessId, );
dateFrom: _service.toTimestamp(weekRangeStart), final DateTime weekRangeEnd = DateTime(
dateTo: _service.toTimestamp(weekRangeEnd), monday.year,
) monday.month,
.execute(); 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 weeklySpending = 0.0;
double next7DaysSpending = 0.0; double next7DaysSpending = 0.0;
int weeklyShifts = 0; int weeklyShifts = 0;
int next7DaysScheduled = 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(); final DateTime? shiftDate = shift.date?.toDateTime();
if (shiftDate == null) { if (shiftDate == null) {
continue; continue;
@@ -58,17 +75,27 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
} }
final DateTime start = DateTime(now.year, now.month, now.day); 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<dc.ListShiftRolesByBusinessAndDateRangeData, final fdc.QueryResult<
dc.ListShiftRolesByBusinessAndDateRangeVariables> result = dc.ListShiftRolesByBusinessAndDateRangeData,
await _service.connector dc.ListShiftRolesByBusinessAndDateRangeVariables
.listShiftRolesByBusinessAndDateRange( >
businessId: businessId, result = await _service.connector
start: _service.toTimestamp(start), .listShiftRolesByBusinessAndDateRange(
end: _service.toTimestamp(end), businessId: businessId,
) start: _service.toTimestamp(start),
.execute(); end: _service.toTimestamp(end),
)
.execute();
int totalNeeded = 0; int totalNeeded = 0;
int totalFilled = 0; int totalFilled = 0;
@@ -90,12 +117,47 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
} }
@override @override
UserSessionData getUserSessionData() { Future<UserSessionData> getUserSessionData() async {
final dc.ClientSession? session = dc.ClientSessionStore.instance.session; final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
return UserSessionData( final dc.ClientBusinessSession? business = session?.business;
businessName: session?.business?.businessName ?? '',
photoUrl: null, // Business photo isn't currently in session // 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<dc.GetBusinessByIdData, dc.GetBusinessByIdVariables>
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 @override
@@ -108,33 +170,34 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
final fdc.Timestamp startTimestamp = _service.toTimestamp(start); final fdc.Timestamp startTimestamp = _service.toTimestamp(start);
final fdc.Timestamp endTimestamp = _service.toTimestamp(now); final fdc.Timestamp endTimestamp = _service.toTimestamp(now);
final fdc.QueryResult<dc.ListShiftRolesByBusinessDateRangeCompletedOrdersData, final fdc.QueryResult<
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersVariables> result = dc.ListShiftRolesByBusinessDateRangeCompletedOrdersData,
await _service.connector dc.ListShiftRolesByBusinessDateRangeCompletedOrdersVariables
.listShiftRolesByBusinessDateRangeCompletedOrders( >
businessId: businessId, result = await _service.connector
start: startTimestamp, .listShiftRolesByBusinessDateRangeCompletedOrders(
end: endTimestamp, businessId: businessId,
) start: startTimestamp,
.execute(); end: endTimestamp,
)
.execute();
return result.data.shiftRoles return result.data.shiftRoles.map((
.map(( dc.ListShiftRolesByBusinessDateRangeCompletedOrdersShiftRoles shiftRole,
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersShiftRoles shiftRole, ) {
) { final String location =
final String location = shiftRole.shift.location ?? shiftRole.shift.locationAddress ?? ''; shiftRole.shift.location ?? shiftRole.shift.locationAddress ?? '';
final String type = shiftRole.shift.order.orderType.stringValue; final String type = shiftRole.shift.order.orderType.stringValue;
return ReorderItem( return ReorderItem(
orderId: shiftRole.shift.order.id, orderId: shiftRole.shift.order.id,
title: '${shiftRole.role.name} - ${shiftRole.shift.title}', title: '${shiftRole.role.name} - ${shiftRole.shift.title}',
location: location, location: location,
hourlyRate: shiftRole.role.costPerHour, hourlyRate: shiftRole.role.costPerHour,
hours: shiftRole.hours ?? 0, hours: shiftRole.hours ?? 0,
workers: shiftRole.count, workers: shiftRole.count,
type: type, type: type,
); );
}) }).toList();
.toList();
}); });
} }
} }

View File

@@ -24,7 +24,7 @@ abstract interface class HomeRepositoryInterface {
Future<HomeDashboardData> getDashboardData(); Future<HomeDashboardData> getDashboardData();
/// Fetches the user's session data (business name and photo). /// Fetches the user's session data (business name and photo).
UserSessionData getUserSessionData(); Future<UserSessionData> getUserSessionData();
/// Fetches recently completed shift roles for reorder suggestions. /// Fetches recently completed shift roles for reorder suggestions.
Future<List<ReorderItem>> getRecentReorders(); Future<List<ReorderItem>> getRecentReorders();

View File

@@ -10,7 +10,7 @@ class GetUserSessionDataUseCase {
final HomeRepositoryInterface _repository; final HomeRepositoryInterface _repository;
/// Executes the use case to get session data. /// Executes the use case to get session data.
UserSessionData call() { Future<UserSessionData> call() {
return _repository.getUserSessionData(); return _repository.getUserSessionData();
} }
} }

View File

@@ -40,7 +40,7 @@ class ClientHomeBloc extends Bloc<ClientHomeEvent, ClientHomeState>
emit: emit, emit: emit,
action: () async { action: () async {
// Get session data // Get session data
final UserSessionData sessionData = _getUserSessionDataUseCase(); final UserSessionData sessionData = await _getUserSessionDataUseCase();
// Get dashboard data // Get dashboard data
final HomeDashboardData data = await _getDashboardDataUseCase(); final HomeDashboardData data = await _getDashboardDataUseCase();

View File

@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
/// A widget that displays quick actions for the client. /// A widget that displays quick actions for the client.
class ActionsWidget extends StatelessWidget { class ActionsWidget extends StatelessWidget {
/// Creates an [ActionsWidget]. /// Creates an [ActionsWidget].
const ActionsWidget({ const ActionsWidget({
super.key, super.key,
@@ -12,6 +11,7 @@ class ActionsWidget extends StatelessWidget {
required this.onCreateOrderPressed, required this.onCreateOrderPressed,
this.subtitle, this.subtitle,
}); });
/// Callback when RAPID is pressed. /// Callback when RAPID is pressed.
final VoidCallback onRapidPressed; final VoidCallback onRapidPressed;
@@ -26,12 +26,9 @@ class ActionsWidget extends StatelessWidget {
// Check if client_home exists in t // Check if client_home exists in t
final TranslationsClientHomeActionsEn i18n = t.client_home.actions; final TranslationsClientHomeActionsEn i18n = t.client_home.actions;
return Column( return Row(
crossAxisAlignment: CrossAxisAlignment.start, spacing: UiConstants.space4,
children: <Widget>[ children: <Widget>[
Row(
children: <Widget>[
/// TODO: FEATURE_NOT_YET_IMPLEMENTED
Expanded( Expanded(
child: _ActionCard( child: _ActionCard(
title: i18n.rapid, title: i18n.rapid,
@@ -46,7 +43,6 @@ class ActionsWidget extends StatelessWidget {
onTap: onRapidPressed, onTap: onRapidPressed,
), ),
), ),
// const SizedBox(width: UiConstants.space2),
Expanded( Expanded(
child: _ActionCard( child: _ActionCard(
title: i18n.create_order, title: i18n.create_order,
@@ -62,14 +58,11 @@ class ActionsWidget extends StatelessWidget {
), ),
), ),
], ],
),
],
); );
} }
} }
class _ActionCard extends StatelessWidget { class _ActionCard extends StatelessWidget {
const _ActionCard({ const _ActionCard({
required this.title, required this.title,
required this.subtitle, required this.subtitle,

View File

@@ -16,16 +16,16 @@ import '../../domain/repositories/hub_repository_interface.dart';
/// Implementation of [HubRepositoryInterface] backed by Data Connect. /// Implementation of [HubRepositoryInterface] backed by Data Connect.
class HubRepositoryImpl implements HubRepositoryInterface { class HubRepositoryImpl implements HubRepositoryInterface {
HubRepositoryImpl({ HubRepositoryImpl({required dc.DataConnectService service})
required dc.DataConnectService service, : _service = service;
}) : _service = service;
final dc.DataConnectService _service; final dc.DataConnectService _service;
@override @override
Future<List<domain.Hub>> getHubs() async { Future<List<domain.Hub>> getHubs() async {
return _service.run(() async { return _service.run(() async {
final dc.GetBusinessesByUserIdBusinesses business = await _getBusinessForCurrentUser(); final dc.GetBusinessesByUserIdBusinesses business =
await _getBusinessForCurrentUser();
final String teamId = await _getOrCreateTeamId(business); final String teamId = await _getOrCreateTeamId(business);
return _fetchHubsForTeam(teamId: teamId, businessId: business.id); return _fetchHubsForTeam(teamId: teamId, businessId: business.id);
}); });
@@ -45,10 +45,12 @@ class HubRepositoryImpl implements HubRepositoryInterface {
String? zipCode, String? zipCode,
}) async { }) async {
return _service.run(() async { return _service.run(() async {
final dc.GetBusinessesByUserIdBusinesses business = await _getBusinessForCurrentUser(); final dc.GetBusinessesByUserIdBusinesses business =
await _getBusinessForCurrentUser();
final String teamId = await _getOrCreateTeamId(business); final String teamId = await _getOrCreateTeamId(business);
final _PlaceAddress? placeAddress = final _PlaceAddress? placeAddress = placeId == null || placeId.isEmpty
placeId == null || placeId.isEmpty ? null : await _fetchPlaceAddress(placeId); ? null
: await _fetchPlaceAddress(placeId);
final String? cityValue = city ?? placeAddress?.city ?? business.city; final String? cityValue = city ?? placeAddress?.city ?? business.city;
final String? stateValue = state ?? placeAddress?.state; final String? stateValue = state ?? placeAddress?.state;
final String? streetValue = street ?? placeAddress?.street; final String? streetValue = street ?? placeAddress?.street;
@@ -56,21 +58,17 @@ class HubRepositoryImpl implements HubRepositoryInterface {
final String? zipCodeValue = zipCode ?? placeAddress?.zipCode; final String? zipCodeValue = zipCode ?? placeAddress?.zipCode;
final OperationResult<dc.CreateTeamHubData, dc.CreateTeamHubVariables> final OperationResult<dc.CreateTeamHubData, dc.CreateTeamHubVariables>
result = await _service.connector result = await _service.connector
.createTeamHub( .createTeamHub(teamId: teamId, hubName: name, address: address)
teamId: teamId, .placeId(placeId)
hubName: name, .latitude(latitude)
address: address, .longitude(longitude)
) .city(cityValue?.isNotEmpty == true ? cityValue : '')
.placeId(placeId) .state(stateValue)
.latitude(latitude) .street(streetValue)
.longitude(longitude) .country(countryValue)
.city(cityValue?.isNotEmpty == true ? cityValue : '') .zipCode(zipCodeValue)
.state(stateValue) .execute();
.street(streetValue)
.country(countryValue)
.zipCode(zipCodeValue)
.execute();
final String createdId = result.data.teamHub_insert.id; final String createdId = result.data.teamHub_insert.id;
final List<domain.Hub> hubs = await _fetchHubsForTeam( final List<domain.Hub> hubs = await _fetchHubsForTeam(
@@ -101,14 +99,13 @@ class HubRepositoryImpl implements HubRepositoryInterface {
return _service.run(() async { return _service.run(() async {
final String businessId = await _service.getBusinessId(); final String businessId = await _service.getBusinessId();
final QueryResult<dc.ListOrdersByBusinessAndTeamHubData, final QueryResult<
dc.ListOrdersByBusinessAndTeamHubVariables> result = dc.ListOrdersByBusinessAndTeamHubData,
await _service.connector dc.ListOrdersByBusinessAndTeamHubVariables
.listOrdersByBusinessAndTeamHub( >
businessId: businessId, result = await _service.connector
teamHubId: id, .listOrdersByBusinessAndTeamHub(businessId: businessId, teamHubId: id)
) .execute();
.execute();
if (result.data.orders.isNotEmpty) { if (result.data.orders.isNotEmpty) {
throw HubHasOrdersException( throw HubHasOrdersException(
@@ -121,14 +118,14 @@ class HubRepositoryImpl implements HubRepositoryInterface {
} }
@override @override
Future<void> assignNfcTag({ Future<void> assignNfcTag({required String hubId, required String nfcTagId}) {
required String hubId, throw UnimplementedError(
required String nfcTagId, 'NFC tag assignment is not supported for team hubs.',
}) { );
throw UnimplementedError('NFC tag assignment is not supported for team hubs.');
} }
Future<dc.GetBusinessesByUserIdBusinesses> _getBusinessForCurrentUser() async { Future<dc.GetBusinessesByUserIdBusinesses>
_getBusinessForCurrentUser() async {
final dc.ClientSession? session = dc.ClientSessionStore.instance.session; final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
final dc.ClientBusinessSession? cachedBusiness = session?.business; final dc.ClientBusinessSession? cachedBusiness = session?.business;
if (cachedBusiness != null) { if (cachedBusiness != null) {
@@ -136,7 +133,9 @@ class HubRepositoryImpl implements HubRepositoryInterface {
id: cachedBusiness.id, id: cachedBusiness.id,
businessName: cachedBusiness.businessName, businessName: cachedBusiness.businessName,
userId: _service.auth.currentUser?.uid ?? '', userId: _service.auth.currentUser?.uid ?? '',
rateGroup: const dc.Known<dc.BusinessRateGroup>(dc.BusinessRateGroup.STANDARD), rateGroup: const dc.Known<dc.BusinessRateGroup>(
dc.BusinessRateGroup.STANDARD,
),
status: const dc.Known<dc.BusinessStatus>(dc.BusinessStatus.ACTIVE), status: const dc.Known<dc.BusinessStatus>(dc.BusinessStatus.ACTIVE),
contactName: cachedBusiness.contactName, contactName: cachedBusiness.contactName,
companyLogoUrl: cachedBusiness.companyLogoUrl, companyLogoUrl: cachedBusiness.companyLogoUrl,
@@ -160,11 +159,13 @@ class HubRepositoryImpl implements HubRepositoryInterface {
); );
} }
final QueryResult<dc.GetBusinessesByUserIdData, final QueryResult<
dc.GetBusinessesByUserIdVariables> result = dc.GetBusinessesByUserIdData,
await _service.connector.getBusinessesByUserId( dc.GetBusinessesByUserIdVariables
userId: user.uid, >
).execute(); result = await _service.connector
.getBusinessesByUserId(userId: user.uid)
.execute();
if (result.data.businesses.isEmpty) { if (result.data.businesses.isEmpty) {
await _service.auth.signOut(); await _service.auth.signOut();
throw BusinessNotFoundException( 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) { if (session != null) {
dc.ClientSessionStore.instance.setSession( dc.ClientSessionStore.instance.setSession(
dc.ClientSession( dc.ClientSession(
user: session.user,
userPhotoUrl: session.userPhotoUrl,
business: dc.ClientBusinessSession( business: dc.ClientBusinessSession(
id: business.id, id: business.id,
businessName: business.businessName, businessName: business.businessName,
@@ -197,26 +197,26 @@ class HubRepositoryImpl implements HubRepositoryInterface {
dc.GetBusinessesByUserIdBusinesses business, dc.GetBusinessesByUserIdBusinesses business,
) async { ) async {
final QueryResult<dc.GetTeamsByOwnerIdData, dc.GetTeamsByOwnerIdVariables> final QueryResult<dc.GetTeamsByOwnerIdData, dc.GetTeamsByOwnerIdVariables>
teamsResult = await _service.connector.getTeamsByOwnerId( teamsResult = await _service.connector
ownerId: business.id, .getTeamsByOwnerId(ownerId: business.id)
).execute(); .execute();
if (teamsResult.data.teams.isNotEmpty) { if (teamsResult.data.teams.isNotEmpty) {
return teamsResult.data.teams.first.id; return teamsResult.data.teams.first.id;
} }
final dc.CreateTeamVariablesBuilder createTeamBuilder = _service.connector.createTeam( final dc.CreateTeamVariablesBuilder createTeamBuilder = _service.connector
teamName: '${business.businessName} Team', .createTeam(
ownerId: business.id, teamName: '${business.businessName} Team',
ownerName: business.contactName ?? '', ownerId: business.id,
ownerRole: 'OWNER', ownerName: business.contactName ?? '',
); ownerRole: 'OWNER',
);
if (business.email != null) { if (business.email != null) {
createTeamBuilder.email(business.email); createTeamBuilder.email(business.email);
} }
final OperationResult<dc.CreateTeamData, dc.CreateTeamVariables> final OperationResult<dc.CreateTeamData, dc.CreateTeamVariables>
createTeamResult = createTeamResult = await createTeamBuilder.execute();
await createTeamBuilder.execute();
final String teamId = createTeamResult.data.team_insert.id; final String teamId = createTeamResult.data.team_insert.id;
return teamId; return teamId;
@@ -226,11 +226,13 @@ class HubRepositoryImpl implements HubRepositoryInterface {
required String teamId, required String teamId,
required String businessId, required String businessId,
}) async { }) async {
final QueryResult<dc.GetTeamHubsByTeamIdData, final QueryResult<
dc.GetTeamHubsByTeamIdVariables> hubsResult = dc.GetTeamHubsByTeamIdData,
await _service.connector.getTeamHubsByTeamId( dc.GetTeamHubsByTeamIdVariables
teamId: teamId, >
).execute(); hubsResult = await _service.connector
.getTeamHubsByTeamId(teamId: teamId)
.execute();
return hubsResult.data.teamHubs return hubsResult.data.teamHubs
.map( .map(
@@ -240,10 +242,9 @@ class HubRepositoryImpl implements HubRepositoryInterface {
name: hub.hubName, name: hub.hubName,
address: hub.address, address: hub.address,
nfcTagId: null, nfcTagId: null,
status: status: hub.isActive
hub.isActive ? domain.HubStatus.active
? domain.HubStatus.active : domain.HubStatus.inactive,
: domain.HubStatus.inactive,
), ),
) )
.toList(); .toList();
@@ -288,7 +289,8 @@ class HubRepositoryImpl implements HubRepositoryInterface {
for (final dynamic entry in components) { for (final dynamic entry in components) {
final Map<String, dynamic> component = entry as Map<String, dynamic>; final Map<String, dynamic> component = entry as Map<String, dynamic>;
final List<dynamic> types = component['types'] as List<dynamic>? ?? <dynamic>[]; final List<dynamic> types =
component['types'] as List<dynamic>? ?? <dynamic>[];
final String? longName = component['long_name'] as String?; final String? longName = component['long_name'] as String?;
final String? shortName = component['short_name'] as String?; final String? shortName = component['short_name'] as String?;

View File

@@ -31,7 +31,7 @@ class ClientSettingsPage extends StatelessWidget {
message: 'Signed out successfully', message: 'Signed out successfully',
type: UiSnackbarType.success, type: UiSnackbarType.success,
); );
Modular.to.toClientRoot(); Modular.to.toClientGetStartedPage();
} }
if (state is ClientSettingsError) { if (state is ClientSettingsError) {
UiSnackbar.show( UiSnackbar.show(

View File

@@ -17,8 +17,8 @@ class SettingsProfileHeader extends StatelessWidget {
final dc.ClientSession? session = dc.ClientSessionStore.instance.session; final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
final String businessName = final String businessName =
session?.business?.businessName ?? 'Your Company'; session?.business?.businessName ?? 'Your Company';
final String email = session?.user.email ?? 'client@example.com'; final String email = session?.business?.email ?? 'client@example.com';
final String? photoUrl = session?.userPhotoUrl; final String? photoUrl = session?.business?.companyLogoUrl;
final String avatarLetter = businessName.trim().isNotEmpty final String avatarLetter = businessName.trim().isNotEmpty
? businessName.trim()[0].toUpperCase() ? businessName.trim()[0].toUpperCase()
: 'C'; : 'C';

View File

@@ -17,9 +17,8 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
Completer<String?>? _pendingVerification; Completer<String?>? _pendingVerification;
@override @override
Stream<domain.User?> get currentUser => _service.auth Stream<domain.User?> get currentUser =>
.authStateChanges() _service.auth.authStateChanges().map((User? firebaseUser) {
.map((User? firebaseUser) {
if (firebaseUser == null) { if (firebaseUser == null) {
return null; return null;
} }
@@ -49,20 +48,24 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
// For real numbers, we can support auto-verification if desired. // For real numbers, we can support auto-verification if desired.
// But since this method returns a verificationId for manual OTP entry, // But since this method returns a verificationId for manual OTP entry,
// we might not handle direct sign-in here unless the architecture changes. // 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. // or we could sign in directly if the credential is provided.
}, },
verificationFailed: (FirebaseAuthException e) { verificationFailed: (FirebaseAuthException e) {
if (!completer.isCompleted) { if (!completer.isCompleted) {
// Map Firebase network errors to NetworkException // 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) { e.message?.contains('Unable to resolve host') == true) {
completer.completeError( completer.completeError(
const domain.NetworkException(technicalMessage: 'Auth network failure'), const domain.NetworkException(
technicalMessage: 'Auth network failure',
),
); );
} else { } else {
completer.completeError( 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, verificationId: verificationId,
smsCode: smsCode, smsCode: smsCode,
); );
final UserCredential userCredential = await _service.run( final UserCredential userCredential = await _service.run(() async {
() async { try {
try { return await _service.auth.signInWithCredential(credential);
return await _service.auth.signInWithCredential(credential); } on FirebaseAuthException catch (e) {
} on FirebaseAuthException catch (e) { if (e.code == 'invalid-verification-code') {
if (e.code == 'invalid-verification-code') { throw const domain.InvalidCredentialsException(
throw const domain.InvalidCredentialsException( technicalMessage: 'Invalid OTP code entered.',
technicalMessage: 'Invalid OTP code entered.', );
);
}
rethrow;
} }
}, rethrow;
requiresAuthentication: false, }
); }, requiresAuthentication: false);
final User? firebaseUser = userCredential.user; final User? firebaseUser = userCredential.user;
if (firebaseUser == null) { if (firebaseUser == null) {
throw const domain.SignInFailedException( throw const domain.SignInFailedException(
@@ -135,13 +135,9 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
final QueryResult<GetUserByIdData, GetUserByIdVariables> response = final QueryResult<GetUserByIdData, GetUserByIdVariables> response =
await _service.run( await _service.run(
() => _service.connector () => _service.connector.getUserById(id: firebaseUser.uid).execute(),
.getUserById( requiresAuthentication: false,
id: firebaseUser.uid, );
)
.execute(),
requiresAuthentication: false,
);
final GetUserByIdUser? user = response.data.user; final GetUserByIdUser? user = response.data.user;
GetStaffByUserIdStaffs? staffRecord; GetStaffByUserIdStaffs? staffRecord;
@@ -150,10 +146,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
if (user == null) { if (user == null) {
await _service.run( await _service.run(
() => _service.connector () => _service.connector
.createUser( .createUser(id: firebaseUser.uid, role: UserBaseRole.USER)
id: firebaseUser.uid,
role: UserBaseRole.USER,
)
.userRole('STAFF') .userRole('STAFF')
.execute(), .execute(),
requiresAuthentication: false, requiresAuthentication: false,
@@ -161,11 +154,9 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
} else { } else {
// User exists in PostgreSQL. Check if they have a STAFF profile. // User exists in PostgreSQL. Check if they have a STAFF profile.
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables> final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
staffResponse = await _service.run( staffResponse = await _service.run(
() => _service.connector () => _service.connector
.getStaffByUserId( .getStaffByUserId(userId: firebaseUser.uid)
userId: firebaseUser.uid,
)
.execute(), .execute(),
requiresAuthentication: false, requiresAuthentication: false,
); );
@@ -208,11 +199,9 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
} }
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables> final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
staffResponse = await _service.run( staffResponse = await _service.run(
() => _service.connector () => _service.connector
.getStaffByUserId( .getStaffByUserId(userId: firebaseUser.uid)
userId: firebaseUser.uid,
)
.execute(), .execute(),
requiresAuthentication: false, requiresAuthentication: false,
); );
@@ -257,77 +246,4 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
); );
return domainUser; return domainUser;
} }
@override
Future<domain.User?> restoreSession() async {
final User? firebaseUser = _service.auth.currentUser;
if (firebaseUser == null) {
return null;
}
try {
// 1. Fetch User
final QueryResult<GetUserByIdData, GetUserByIdVariables> 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<GetStaffByUserIdData, GetStaffByUserIdVariables>
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;
}
}
} }

View File

@@ -20,7 +20,4 @@ abstract interface class AuthRepositoryInterface {
/// Signs out the current user. /// Signs out the current user.
Future<void> signOut(); Future<void> signOut();
/// Restores the session if a user is already logged in.
Future<User?> restoreSession();
} }

View File

@@ -1,65 +1,14 @@
import 'dart:async'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.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}); const IntroPage({super.key});
@override
State<IntroPage> createState() => _IntroPageState();
}
class _IntroPageState extends State<IntroPage> {
@override
void initState() {
super.initState();
_checkSession();
}
Future<void> _checkSession() async {
// Check session immediately without artificial delay
if (!mounted) return;
try {
final AuthRepositoryInterface authRepo = Modular.get<AuthRepositoryInterface>();
// 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface, body: Center(child: Image.asset(UiImageAssets.logoYellow, width: 120)),
body: Center(
child: Image.asset(
'assets/logo-yellow.png',
package: 'design_system',
width: 120,
),
),
); );
} }
} }

View File

@@ -58,15 +58,14 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
} }
if (normalized.length == 10) { if (normalized.length == 10) {
BlocProvider.of<AuthBloc>( BlocProvider.of<AuthBloc>(context).add(
context,
).add(
AuthSignInRequested(phoneNumber: '+1$normalized', mode: widget.mode), AuthSignInRequested(phoneNumber: '+1$normalized', mode: widget.mode),
); );
} else { } else {
UiSnackbar.show( UiSnackbar.show(
context, context,
message: t.staff_authentication.phone_verification_page.validation_error, message:
t.staff_authentication.phone_verification_page.validation_error,
type: UiSnackbarType.error, type: UiSnackbarType.error,
margin: const EdgeInsets.only(bottom: 180, left: 16, right: 16), margin: const EdgeInsets.only(bottom: 180, left: 16, right: 16),
); );
@@ -79,9 +78,7 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
required String otp, required String otp,
required String verificationId, required String verificationId,
}) { }) {
BlocProvider.of<AuthBloc>( BlocProvider.of<AuthBloc>(context).add(
context,
).add(
AuthOtpSubmitted( AuthOtpSubmitted(
verificationId: verificationId, verificationId: verificationId,
smsCode: otp, smsCode: otp,
@@ -92,9 +89,9 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
/// Handles the request to resend the verification code using the phone number in the state. /// Handles the request to resend the verification code using the phone number in the state.
void _onResend({required BuildContext context}) { void _onResend({required BuildContext context}) {
BlocProvider.of<AuthBloc>(context).add( BlocProvider.of<AuthBloc>(
AuthSignInRequested(mode: widget.mode), context,
); ).add(AuthSignInRequested(mode: widget.mode));
} }
@override @override
@@ -108,8 +105,6 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
if (state.status == AuthStatus.authenticated) { if (state.status == AuthStatus.authenticated) {
if (state.mode == AuthMode.signup) { if (state.mode == AuthMode.signup) {
Modular.to.toProfileSetup(); Modular.to.toProfileSetup();
} else {
Modular.to.toStaffHome();
} }
} else if (state.status == AuthStatus.error && } else if (state.status == AuthStatus.error &&
state.mode == AuthMode.signup) { state.mode == AuthMode.signup) {
@@ -120,7 +115,11 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
context, context,
message: translateErrorKey(messageKey), message: translateErrorKey(messageKey),
type: UiSnackbarType.error, type: UiSnackbarType.error,
margin: const EdgeInsets.only(bottom: 180, left: 16, right: 16), margin: const EdgeInsets.only(
bottom: 180,
left: 16,
right: 16,
),
); );
Future<void>.delayed(const Duration(seconds: 5), () { Future<void>.delayed(const Duration(seconds: 5), () {
if (!mounted) return; if (!mounted) return;
@@ -153,9 +152,9 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
centerTitle: true, centerTitle: true,
showBackButton: true, showBackButton: true,
onLeadingPressed: () { onLeadingPressed: () {
BlocProvider.of<AuthBloc>(context).add( BlocProvider.of<AuthBloc>(
AuthResetRequested(mode: widget.mode), context,
); ).add(AuthResetRequested(mode: widget.mode));
Navigator.of(context).pop(); Navigator.of(context).pop();
}, },
), ),
@@ -175,13 +174,13 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
verificationId: state.verificationId ?? '', verificationId: state.verificationId ?? '',
), ),
) )
: PhoneInput( : PhoneInput(
state: state, state: state,
onSendCode: (String phoneNumber) => _onSendCode( onSendCode: (String phoneNumber) => _onSendCode(
context: context, context: context,
phoneNumber: phoneNumber, phoneNumber: phoneNumber,
),
), ),
),
), ),
), ),
); );

View File

@@ -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/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart' hide ReadContext; import 'package:flutter_bloc/flutter_bloc.dart' hide ReadContext;
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:core_localization/core_localization.dart'; import 'package:krow_core/core.dart';
import 'package:design_system/design_system.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../blocs/profile_cubit.dart'; import '../blocs/profile_cubit.dart';
import '../blocs/profile_state.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/logout_button.dart';
import '../widgets/profile_header.dart';
import '../widgets/profile_menu_grid.dart'; import '../widgets/profile_menu_grid.dart';
import '../widgets/profile_menu_item.dart'; import '../widgets/profile_menu_item.dart';
import '../widgets/profile_header.dart';
import '../widgets/reliability_score_bar.dart'; import '../widgets/reliability_score_bar.dart';
import '../widgets/reliability_stats_card.dart'; import '../widgets/reliability_stats_card.dart';
import '../widgets/reliability_stats_card.dart';
import '../widgets/section_title.dart'; import '../widgets/section_title.dart';
import '../widgets/language_selector_bottom_sheet.dart';
/// The main Staff Profile page. /// The main Staff Profile page.
/// ///
@@ -63,7 +62,7 @@ class StaffProfilePage extends StatelessWidget {
bloc: cubit, bloc: cubit,
listener: (context, state) { listener: (context, state) {
if (state.status == ProfileStatus.signedOut) { if (state.status == ProfileStatus.signedOut) {
Modular.to.toGetStarted(); Modular.to.toGetStartedPage();
} else if (state.status == ProfileStatus.error && } else if (state.status == ProfileStatus.error &&
state.errorMessage != null) { state.errorMessage != null) {
UiSnackbar.show( UiSnackbar.show(

View File

@@ -14,13 +14,10 @@ class BankAccountRepositoryImpl implements BankAccountRepository {
final DataConnectService _service; final DataConnectService _service;
@override @override
Future<List<BankAccount>> getAccounts() async { Future<List<StaffBankAccount>> getAccounts() async {
return _service.run(() async { return _service.run(() async {
final String staffId = await _service.getStaffId(); final String staffId = await _service.getStaffId();
var x = staffId;
print(x);
final QueryResult<GetAccountsByOwnerIdData, GetAccountsByOwnerIdVariables> final QueryResult<GetAccountsByOwnerIdData, GetAccountsByOwnerIdVariables>
result = await _service.connector result = await _service.connector
.getAccountsByOwnerId(ownerId: staffId) .getAccountsByOwnerId(ownerId: staffId)
@@ -44,7 +41,7 @@ class BankAccountRepositoryImpl implements BankAccountRepository {
} }
@override @override
Future<void> addAccount(BankAccount account) async { Future<void> addAccount(StaffBankAccount account) async {
return _service.run(() async { return _service.run(() async {
final String staffId = await _service.getStaffId(); final String staffId = await _service.getStaffId();

View File

@@ -4,7 +4,7 @@ import 'package:krow_domain/krow_domain.dart';
/// Arguments for adding a bank account. /// Arguments for adding a bank account.
class AddBankAccountParams extends UseCaseArgument with EquatableMixin { class AddBankAccountParams extends UseCaseArgument with EquatableMixin {
final BankAccount account; final StaffBankAccount account;
const AddBankAccountParams({required this.account}); const AddBankAccountParams({required this.account});

View File

@@ -3,8 +3,8 @@ import 'package:krow_domain/krow_domain.dart';
/// Repository interface for managing bank accounts. /// Repository interface for managing bank accounts.
abstract class BankAccountRepository { abstract class BankAccountRepository {
/// Fetches the list of bank accounts for the current user. /// Fetches the list of bank accounts for the current user.
Future<List<BankAccount>> getAccounts(); Future<List<StaffBankAccount>> getAccounts();
/// adds a new bank account. /// adds a new bank account.
Future<void> addAccount(BankAccount account); Future<void> addAccount(StaffBankAccount account);
} }

View File

@@ -3,13 +3,13 @@ import 'package:krow_domain/krow_domain.dart';
import '../repositories/bank_account_repository.dart'; import '../repositories/bank_account_repository.dart';
/// Use case to fetch bank accounts. /// Use case to fetch bank accounts.
class GetBankAccountsUseCase implements NoInputUseCase<List<BankAccount>> { class GetBankAccountsUseCase implements NoInputUseCase<List<StaffBankAccount>> {
final BankAccountRepository _repository; final BankAccountRepository _repository;
GetBankAccountsUseCase(this._repository); GetBankAccountsUseCase(this._repository);
@override @override
Future<List<BankAccount>> call() { Future<List<StaffBankAccount>> call() {
return _repository.getAccounts(); return _repository.getAccounts();
} }
} }

View File

@@ -23,19 +23,15 @@ class BankAccountCubit extends Cubit<BankAccountState>
await handleError( await handleError(
emit: emit, emit: emit,
action: () async { action: () async {
final List<BankAccount> accounts = await _getBankAccountsUseCase(); final List<StaffBankAccount> accounts = await _getBankAccountsUseCase();
emit( emit(
state.copyWith( state.copyWith(status: BankAccountStatus.loaded, accounts: accounts),
status: BankAccountStatus.loaded,
accounts: accounts,
),
); );
}, },
onError: onError: (String errorKey) => state.copyWith(
(String errorKey) => state.copyWith( status: BankAccountStatus.error,
status: BankAccountStatus.error, errorMessage: errorKey,
errorMessage: errorKey, ),
),
); );
} }
@@ -52,21 +48,18 @@ class BankAccountCubit extends Cubit<BankAccountState>
emit(state.copyWith(status: BankAccountStatus.loading)); emit(state.copyWith(status: BankAccountStatus.loading));
// Create domain entity // Create domain entity
final BankAccount newAccount = BankAccount( final StaffBankAccount newAccount = StaffBankAccount(
id: '', // Generated by server usually id: '', // Generated by server usually
userId: '', // Handled by Repo/Auth userId: '', // Handled by Repo/Auth
bankName: bankName, bankName: bankName,
accountNumber: accountNumber, accountNumber: accountNumber.length > 4
? accountNumber.substring(accountNumber.length - 4)
: accountNumber,
accountName: '', accountName: '',
sortCode: routingNumber, sortCode: routingNumber,
type: type: type == 'CHECKING'
type == 'CHECKING' ? StaffBankAccountType.checking
? BankAccountType.checking : StaffBankAccountType.savings,
: BankAccountType.savings,
last4:
accountNumber.length > 4
? accountNumber.substring(accountNumber.length - 4)
: accountNumber,
isPrimary: false, isPrimary: false,
); );
@@ -85,12 +78,10 @@ class BankAccountCubit extends Cubit<BankAccountState>
), ),
); );
}, },
onError: onError: (String errorKey) => state.copyWith(
(String errorKey) => state.copyWith( status: BankAccountStatus.error,
status: BankAccountStatus.error, errorMessage: errorKey,
errorMessage: errorKey, ),
),
); );
} }
} }

View File

@@ -5,7 +5,7 @@ enum BankAccountStatus { initial, loading, loaded, error, accountAdded }
class BankAccountState extends Equatable { class BankAccountState extends Equatable {
final BankAccountStatus status; final BankAccountStatus status;
final List<BankAccount> accounts; final List<StaffBankAccount> accounts;
final String? errorMessage; final String? errorMessage;
final bool showForm; final bool showForm;
@@ -18,7 +18,7 @@ class BankAccountState extends Equatable {
BankAccountState copyWith({ BankAccountState copyWith({
BankAccountStatus? status, BankAccountStatus? status,
List<BankAccount>? accounts, List<StaffBankAccount>? accounts,
String? errorMessage, String? errorMessage,
bool? showForm, bool? showForm,
}) { }) {

View File

@@ -96,7 +96,7 @@ class BankAccountPage extends StatelessWidget {
style: UiTypography.headline4m.copyWith(color: UiColors.textPrimary), style: UiTypography.headline4m.copyWith(color: UiColors.textPrimary),
), ),
const SizedBox(height: UiConstants.space3), 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 // Add extra padding at bottom
const SizedBox(height: UiConstants.space20), 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; final bool isPrimary = account.isPrimary;
const Color primaryColor = UiColors.primary; const Color primaryColor = UiColors.primary;

View File

@@ -141,10 +141,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.0"
charcode: charcode:
dependency: transitive dependency: transitive
description: description:
@@ -741,6 +741,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.5" version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation: json_annotation:
dependency: transitive dependency: transitive
description: description:
@@ -809,18 +817,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.18" version: "0.12.17"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.0" version: "0.11.1"
melos: melos:
dependency: "direct dev" dependency: "direct dev"
description: description:
@@ -1318,26 +1326,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test name: test
sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a" sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.29.0" version: "1.26.3"
test_api: test_api:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.9" version: "0.7.7"
test_core: test_core:
dependency: transitive dependency: transitive
description: description:
name: test_core name: test_core
sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943" sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.15" version: "0.6.12"
typed_data: typed_data:
dependency: transitive dependency: transitive
description: description:

View File

@@ -1,6 +1,6 @@
# --- Mobile App Development --- # --- 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 MOBILE_DIR := apps/mobile
@@ -19,6 +19,10 @@ mobile-info:
@echo "--> Fetching mobile command info..." @echo "--> Fetching mobile command info..."
@cd $(MOBILE_DIR) && melos run 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 --- # --- Hot Reload & Restart ---
mobile-hot-reload: mobile-hot-reload:
@echo "--> Triggering hot reload for running Flutter app..." @echo "--> Triggering hot reload for running Flutter app..."