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:
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
163
apps/mobile/apps/client/lib/src/widgets/session_listener.dart
Normal file
163
apps/mobile/apps/client/lib/src/widgets/session_listener.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
163
apps/mobile/apps/staff/lib/src/widgets/session_listener.dart
Normal file
163
apps/mobile/apps/staff/lib/src/widgets/session_listener.dart
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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._();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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!;
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -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];
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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>(),
|
||||||
|
|||||||
@@ -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,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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?;
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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});
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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..."
|
||||||
|
|||||||
Reference in New Issue
Block a user