Merge branch 'dev' into codex/local-dev-fixes

This commit is contained in:
Achintha Isuru
2026-02-17 16:34:58 -05:00
committed by GitHub
167 changed files with 5918 additions and 4286 deletions

View File

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

View File

@@ -1,3 +1,5 @@
import java.util.Base64
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
@@ -6,6 +8,18 @@ plugins {
id("com.google.gms.google-services") id("com.google.gms.google-services")
} }
val dartDefinesString = project.findProperty("dart-defines") as? String ?: ""
val dartEnvironmentVariables = mutableMapOf<String, String>()
dartDefinesString.split(",").forEach {
if (it.isNotEmpty()) {
val decoded = String(Base64.getDecoder().decode(it))
val components = decoded.split("=")
if (components.size == 2) {
dartEnvironmentVariables[components[0]] = components[1]
}
}
}
android { android {
namespace = "com.krowwithus.client" namespace = "com.krowwithus.client"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
@@ -29,6 +43,8 @@ android {
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartEnvironmentVariables["GOOGLE_MAPS_API_KEY"] ?: ""
} }
buildTypes { buildTypes {

View File

@@ -30,6 +30,9 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="${GOOGLE_MAPS_API_KEY}" />
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and

View File

@@ -1,5 +1,6 @@
import Flutter import Flutter
import UIKit import UIKit
import GoogleMaps
@main @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate {
@@ -7,7 +8,31 @@ import UIKit
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
if let apiKey = getDartDefine(key: "GOOGLE_MAPS_API_KEY") {
GMSServices.provideAPIKey(apiKey)
}
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
private func getDartDefine(key: String) -> String? {
guard let dartDefines = Bundle.main.infoDictionary?["DART_DEFINES"] as? String else {
return nil
}
let defines = dartDefines.components(separatedBy: ",")
for define in defines {
guard let decodedData = Data(base64Encoded: define),
let decodedString = String(data: decodedData, encoding: .utf8) else {
continue
}
let components = decodedString.components(separatedBy: "=")
if components.count == 2 && components[0] == key {
return components[1]
}
}
return nil
}
} }

View File

@@ -45,5 +45,7 @@
<true/> <true/>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>DART_DEFINES</key>
<string>$(DART_DEFINES)</string>
</dict> </dict>
</plist> </plist>

View File

@@ -14,8 +14,10 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'firebase_options.dart'; import 'firebase_options.dart';
import 'src/widgets/session_listener.dart';
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
@@ -29,7 +31,17 @@ void main() async {
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>['CLIENT', 'BUSINESS', 'BOTH'], // Only allow users with CLIENT, BUSINESS, or BOTH roles
);
runApp(
ModularApp(
module: AppModule(),
child: const SessionListener(child: AppWidget()),
),
);
} }
/// The main application module for the Client app. /// The main application module for the Client app.

View File

@@ -0,0 +1,163 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
/// A widget that listens to session state changes and handles global reactions.
///
/// This widget wraps the entire app and provides centralized session management,
/// such as logging out when the session expires or handling session errors.
class SessionListener extends StatefulWidget {
/// Creates a [SessionListener].
const SessionListener({required this.child, super.key});
/// The child widget to wrap.
final Widget child;
@override
State<SessionListener> createState() => _SessionListenerState();
}
class _SessionListenerState extends State<SessionListener> {
late StreamSubscription<SessionState> _sessionSubscription;
bool _sessionExpiredDialogShown = false;
bool _isInitialState = true;
@override
void initState() {
super.initState();
_setupSessionListener();
}
void _setupSessionListener() {
_sessionSubscription = DataConnectService.instance.onSessionStateChanged
.listen((SessionState state) {
_handleSessionChange(state);
});
debugPrint('[SessionListener] Initialized session listener');
}
void _handleSessionChange(SessionState state) {
if (!mounted) return;
switch (state.type) {
case SessionStateType.unauthenticated:
debugPrint(
'[SessionListener] Unauthenticated: Session expired or user logged out',
);
// On initial state (cold start), just proceed to login without dialog
// Only show dialog if user was previously authenticated (session expired)
if (_isInitialState) {
_isInitialState = false;
Modular.to.toClientGetStartedPage();
} else if (!_sessionExpiredDialogShown) {
_sessionExpiredDialogShown = true;
_showSessionExpiredDialog();
}
break;
case SessionStateType.authenticated:
// Session restored or user authenticated
_isInitialState = false;
_sessionExpiredDialogShown = false;
debugPrint('[SessionListener] Authenticated: ${state.userId}');
// Navigate to the main app
Modular.to.toClientHome();
break;
case SessionStateType.error:
// Show error notification with option to retry or logout
// Only show if not initial state (avoid showing on cold start)
if (!_isInitialState) {
debugPrint('[SessionListener] Session error: ${state.errorMessage}');
_showSessionErrorDialog(state.errorMessage ?? 'Session error occurred');
} else {
_isInitialState = false;
Modular.to.toClientGetStartedPage();
}
break;
case SessionStateType.loading:
// Session is loading, optionally show a loading indicator
debugPrint('[SessionListener] Session loading...');
break;
}
}
/// Shows a dialog when the session expires.
void _showSessionExpiredDialog() {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Session Expired'),
content: const Text(
'Your session has expired. Please log in again to continue.',
),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.of(context).pop();
_proceedToLogin();
},
child: const Text('Log In'),
),
],
);
},
);
}
/// Shows a dialog when a session error occurs, with retry option.
void _showSessionErrorDialog(String errorMessage) {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Session Error'),
content: Text(errorMessage),
actions: <Widget>[
TextButton(
onPressed: () {
// User can retry by dismissing and continuing
Modular.to.pop();
},
child: const Text('Continue'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
_proceedToLogin();
},
child: const Text('Log Out'),
),
],
);
},
);
}
/// Navigate to login screen and clear navigation stack.
void _proceedToLogin() {
// Clear service caches on sign-out
DataConnectService.instance.handleSignOut();
// Navigate to authentication
Modular.to.toClientGetStartedPage();
}
@override
void dispose() {
_sessionSubscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}

View File

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

View File

@@ -1,3 +1,5 @@
import java.util.Base64
plugins { plugins {
id("com.android.application") id("com.android.application")
id("kotlin-android") id("kotlin-android")
@@ -6,6 +8,18 @@ plugins {
id("com.google.gms.google-services") id("com.google.gms.google-services")
} }
val dartDefinesString = project.findProperty("dart-defines") as? String ?: ""
val dartEnvironmentVariables = mutableMapOf<String, String>()
dartDefinesString.split(",").forEach {
if (it.isNotEmpty()) {
val decoded = String(Base64.getDecoder().decode(it))
val components = decoded.split("=")
if (components.size == 2) {
dartEnvironmentVariables[components[0]] = components[1]
}
}
}
android { android {
namespace = "com.krowwithus.staff" namespace = "com.krowwithus.staff"
compileSdk = flutter.compileSdkVersion compileSdk = flutter.compileSdkVersion
@@ -29,6 +43,8 @@ android {
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartEnvironmentVariables["GOOGLE_MAPS_API_KEY"] ?: ""
} }
buildTypes { buildTypes {

View File

@@ -30,6 +30,9 @@
<meta-data <meta-data
android:name="flutterEmbedding" android:name="flutterEmbedding"
android:value="2" /> android:value="2" />
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="${GOOGLE_MAPS_API_KEY}" />
</application> </application>
<!-- Required to query activities that can process text, see: <!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and https://developer.android.com/training/package-visibility and

View File

@@ -30,11 +30,21 @@ public final class GeneratedPluginRegistrant {
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "Error registering plugin firebase_core, io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin", e); Log.e(TAG, "Error registering plugin firebase_core, io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin", e);
} }
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e);
}
try { try {
flutterEngine.getPlugins().add(new com.baseflow.geolocator.GeolocatorPlugin()); flutterEngine.getPlugins().add(new com.baseflow.geolocator.GeolocatorPlugin());
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "Error registering plugin geolocator_android, com.baseflow.geolocator.GeolocatorPlugin", e); Log.e(TAG, "Error registering plugin geolocator_android, com.baseflow.geolocator.GeolocatorPlugin", e);
} }
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.googlemaps.GoogleMapsPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin google_maps_flutter_android, io.flutter.plugins.googlemaps.GoogleMapsPlugin", e);
}
try { try {
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin()); flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
} catch (Exception e) { } catch (Exception e) {
@@ -50,5 +60,10 @@ public final class GeneratedPluginRegistrant {
} catch (Exception e) { } catch (Exception e) {
Log.e(TAG, "Error registering plugin shared_preferences_android, io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin", e); Log.e(TAG, "Error registering plugin shared_preferences_android, io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin", e);
} }
try {
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
} catch (Exception e) {
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
}
} }
} }

View File

@@ -1,5 +1,6 @@
import Flutter import Flutter
import UIKit import UIKit
import GoogleMaps
@main @main
@objc class AppDelegate: FlutterAppDelegate { @objc class AppDelegate: FlutterAppDelegate {
@@ -7,7 +8,31 @@ import UIKit
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
if let apiKey = getDartDefine(key: "GOOGLE_MAPS_API_KEY") {
GMSServices.provideAPIKey(apiKey)
}
GeneratedPluginRegistrant.register(with: self) GeneratedPluginRegistrant.register(with: self)
return super.application(application, didFinishLaunchingWithOptions: launchOptions) return super.application(application, didFinishLaunchingWithOptions: launchOptions)
} }
private func getDartDefine(key: String) -> String? {
guard let dartDefines = Bundle.main.infoDictionary?["DART_DEFINES"] as? String else {
return nil
}
let defines = dartDefines.components(separatedBy: ",")
for define in defines {
guard let decodedData = Data(base64Encoded: define),
let decodedString = String(data: decodedData, encoding: .utf8) else {
continue
}
let components = decodedString.components(separatedBy: "=")
if components.count == 2 && components[0] == key {
return components[1]
}
}
return nil
}
} }

View File

@@ -30,6 +30,12 @@
@import geolocator_apple; @import geolocator_apple;
#endif #endif
#if __has_include(<google_maps_flutter_ios/FLTGoogleMapsPlugin.h>)
#import <google_maps_flutter_ios/FLTGoogleMapsPlugin.h>
#else
@import google_maps_flutter_ios;
#endif
#if __has_include(<permission_handler_apple/PermissionHandlerPlugin.h>) #if __has_include(<permission_handler_apple/PermissionHandlerPlugin.h>)
#import <permission_handler_apple/PermissionHandlerPlugin.h> #import <permission_handler_apple/PermissionHandlerPlugin.h>
#else #else
@@ -42,6 +48,12 @@
@import shared_preferences_foundation; @import shared_preferences_foundation;
#endif #endif
#if __has_include(<url_launcher_ios/URLLauncherPlugin.h>)
#import <url_launcher_ios/URLLauncherPlugin.h>
#else
@import url_launcher_ios;
#endif
@implementation GeneratedPluginRegistrant @implementation GeneratedPluginRegistrant
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry { + (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
@@ -49,8 +61,10 @@
[FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]]; [FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]];
[FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]];
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]]; [GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
[FLTGoogleMapsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTGoogleMapsPlugin"]];
[PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]]; [PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]];
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
} }
@end @end

View File

@@ -45,5 +45,7 @@
<true/> <true/>
<key>UIApplicationSupportsIndirectInputEvents</key> <key>UIApplicationSupportsIndirectInputEvents</key>
<true/> <true/>
<key>DART_DEFINES</key>
<string>$(DART_DEFINES)</string>
</dict> </dict>
</plist> </plist>

View File

@@ -5,17 +5,18 @@ 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(
@@ -23,7 +24,17 @@ void main() async {
logStateChanges: false, // Set to true for verbose debugging logStateChanges: false, // Set to true for verbose debugging
); );
runApp(ModularApp(module: AppModule(), child: const AppWidget())); // Initialize session listener for Firebase Auth state changes
DataConnectService.instance.initializeAuthListener(
allowedRoles: <String>['STAFF', 'BOTH'], // Only allow users with STAFF or BOTH roles
);
runApp(
ModularApp(
module: AppModule(),
child: const SessionListener(child: AppWidget()),
),
);
} }
/// The main application module. /// The main application module.
@@ -34,7 +45,10 @@ class AppModule extends Module {
@override @override
void routes(RouteManager r) { void routes(RouteManager r) {
// Set the initial route to the authentication module // Set the initial route to the authentication module
r.module(StaffPaths.root, module: staff_authentication.StaffAuthenticationModule()); r.module(
StaffPaths.root,
module: staff_authentication.StaffAuthenticationModule(),
);
r.module(StaffPaths.main, module: staff_main.StaffMainModule()); r.module(StaffPaths.main, module: staff_main.StaffMainModule());
} }

View File

@@ -0,0 +1,163 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
/// A widget that listens to session state changes and handles global reactions.
///
/// This widget wraps the entire app and provides centralized session management,
/// such as logging out when the session expires or handling session errors.
class SessionListener extends StatefulWidget {
/// Creates a [SessionListener].
const SessionListener({required this.child, super.key});
/// The child widget to wrap.
final Widget child;
@override
State<SessionListener> createState() => _SessionListenerState();
}
class _SessionListenerState extends State<SessionListener> {
late StreamSubscription<SessionState> _sessionSubscription;
bool _sessionExpiredDialogShown = false;
bool _isInitialState = true;
@override
void initState() {
super.initState();
_setupSessionListener();
}
void _setupSessionListener() {
_sessionSubscription = DataConnectService.instance.onSessionStateChanged
.listen((SessionState state) {
_handleSessionChange(state);
});
debugPrint('[SessionListener] Initialized session listener');
}
void _handleSessionChange(SessionState state) {
if (!mounted) return;
switch (state.type) {
case SessionStateType.unauthenticated:
debugPrint(
'[SessionListener] Unauthenticated: Session expired or user logged out',
);
// On initial state (cold start), just proceed to login without dialog
// Only show dialog if user was previously authenticated (session expired)
if (_isInitialState) {
_isInitialState = false;
Modular.to.toGetStartedPage();
} else if (!_sessionExpiredDialogShown) {
_sessionExpiredDialogShown = true;
_showSessionExpiredDialog();
}
break;
case SessionStateType.authenticated:
// Session restored or user authenticated
_isInitialState = false;
_sessionExpiredDialogShown = false;
debugPrint('[SessionListener] Authenticated: ${state.userId}');
// Navigate to the main app
Modular.to.toStaffHome();
break;
case SessionStateType.error:
// Show error notification with option to retry or logout
// Only show if not initial state (avoid showing on cold start)
if (!_isInitialState) {
debugPrint('[SessionListener] Session error: ${state.errorMessage}');
_showSessionErrorDialog(state.errorMessage ?? 'Session error occurred');
} else {
_isInitialState = false;
Modular.to.toGetStartedPage();
}
break;
case SessionStateType.loading:
// Session is loading, optionally show a loading indicator
debugPrint('[SessionListener] Session loading...');
break;
}
}
/// Shows a dialog when the session expires.
void _showSessionExpiredDialog() {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Session Expired'),
content: const Text(
'Your session has expired. Please log in again to continue.',
),
actions: <Widget>[
TextButton(
onPressed: () {
Navigator.of(context).pop();
_proceedToLogin();
},
child: const Text('Log In'),
),
],
);
},
);
}
/// Shows a dialog when a session error occurs, with retry option.
void _showSessionErrorDialog(String errorMessage) {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Session Error'),
content: Text(errorMessage),
actions: <Widget>[
TextButton(
onPressed: () {
// User can retry by dismissing and continuing
Modular.to.pop();
},
child: const Text('Continue'),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
_proceedToLogin();
},
child: const Text('Log Out'),
),
],
);
},
);
}
/// Navigate to login screen and clear navigation stack.
void _proceedToLogin() {
// Clear service caches on sign-out
DataConnectService.instance.handleSignOut();
// Navigate to authentication
Modular.to.toGetStartedPage();
}
@override
void dispose() {
_sessionSubscription.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) => widget.child;
}

View File

@@ -6,6 +6,10 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <url_launcher_linux/url_launcher_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) { void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
} }

View File

@@ -3,6 +3,7 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_linux
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -10,6 +10,7 @@ import firebase_auth
import firebase_core import firebase_core
import geolocator_apple import geolocator_apple
import shared_preferences_foundation import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
@@ -17,4 +18,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
} }

View File

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

View File

@@ -10,6 +10,7 @@
#include <firebase_core/firebase_core_plugin_c_api.h> #include <firebase_core/firebase_core_plugin_c_api.h>
#include <geolocator_windows/geolocator_windows.h> #include <geolocator_windows/geolocator_windows.h>
#include <permission_handler_windows/permission_handler_windows_plugin.h> #include <permission_handler_windows/permission_handler_windows_plugin.h>
#include <url_launcher_windows/url_launcher_windows.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
FirebaseAuthPluginCApiRegisterWithRegistrar( FirebaseAuthPluginCApiRegisterWithRegistrar(
@@ -20,4 +21,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("GeolocatorWindows")); registry->GetRegistrarForPlugin("GeolocatorWindows"));
PermissionHandlerWindowsPluginRegisterWithRegistrar( PermissionHandlerWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
UrlLauncherWindowsRegisterWithRegistrar(
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
} }

View File

@@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
firebase_core firebase_core
geolocator_windows geolocator_windows
permission_handler_windows permission_handler_windows
url_launcher_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -1,3 +1,3 @@
{ {
"GOOGLE_PLACES_API_KEY": "AIzaSyAS9yTf4q51_CNSZ7mbmeS9V3l_LZR80lU" "GOOGLE_MAPS_API_KEY": "AIzaSyAyRS9I4xxoVPAX91RJvWJHszB3ZY3-IC0"
} }

View File

@@ -4,6 +4,6 @@
class AppConfig { class AppConfig {
AppConfig._(); AppConfig._();
/// The Google Places API key used for address autocomplete functionality. /// The Google Maps API key.
static const String googlePlacesApiKey = String.fromEnvironment('GOOGLE_PLACES_API_KEY'); static const String googleMapsApiKey = String.fromEnvironment('GOOGLE_MAPS_API_KEY');
} }

View File

@@ -21,7 +21,7 @@ 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
@@ -35,6 +35,13 @@ extension ClientNavigator on IModularNavigator {
navigate(ClientPaths.root); navigate(ClientPaths.root);
} }
/// Navigates to the get started page.
///
/// This is the landing page for unauthenticated users, offering login/signup options.
void toClientGetStartedPage() {
navigate(ClientPaths.getStarted);
}
/// Navigates to the client sign-in page. /// Navigates to the client sign-in page.
/// ///
/// This page allows existing clients to log in using email/password /// This page allows existing clients to log in using email/password

View File

@@ -41,6 +41,11 @@ class ClientPaths {
/// This serves as the entry point for unauthenticated users. /// This serves as the entry point for unauthenticated users.
static const String root = '/'; static const String root = '/';
/// Get Started page (relative path within auth module).
///
/// The landing page for unauthenticated users, offering login/signup options.
static const String getStarted = '/get-started';
/// Sign-in page where existing clients can log into their account. /// Sign-in page where existing clients can log into their account.
/// ///
/// Supports email/password and social authentication. /// Supports email/password and social authentication.

View File

@@ -32,10 +32,17 @@ extension StaffNavigator on IModularNavigator {
/// ///
/// This effectively logs out the user by navigating to root. /// This effectively logs out the user by navigating to root.
/// Used when signing out or session expires. /// Used when signing out or session expires.
void toGetStarted() { void toInitialPage() {
navigate(StaffPaths.root); navigate(StaffPaths.root);
} }
/// Navigates to the get started page.
///
/// This is the landing page for unauthenticated users, offering login/signup options.
void toGetStartedPage() {
navigate(StaffPaths.getStarted);
}
/// Navigates to the phone verification page. /// Navigates to the phone verification page.
/// ///
/// Used for both login and signup flows to verify phone numbers via OTP. /// Used for both login and signup flows to verify phone numbers via OTP.

View File

@@ -41,6 +41,11 @@ class StaffPaths {
/// This serves as the entry point for unauthenticated staff members. /// This serves as the entry point for unauthenticated staff members.
static const String root = '/'; static const String root = '/';
/// Get Started page (relative path within auth module).
///
/// The landing page for unauthenticated users, offering login/signup options.
static const String getStarted = '/get-started';
/// Phone verification page (relative path within auth module). /// Phone verification page (relative path within auth module).
/// ///
/// Used for both login and signup flows to verify phone numbers via OTP. /// Used for both login and signup flows to verify phone numbers via OTP.

View File

@@ -148,9 +148,17 @@
"edit_mode_active": "Edit Mode Active", "edit_mode_active": "Edit Mode Active",
"drag_instruction": "Drag to reorder, toggle visibility", "drag_instruction": "Drag to reorder, toggle visibility",
"reset": "Reset", "reset": "Reset",
"todays_coverage": "TODAY'S COVERAGE",
"percent_covered": "$percent% Covered",
"metric_needed": "Needed", "metric_needed": "Needed",
"metric_filled": "Filled", "metric_filled": "Filled",
"metric_open": "Open", "metric_open": "Open",
"spending": {
"this_week": "This Week",
"next_7_days": "Next 7 Days",
"shifts_count": "$count shifts",
"scheduled_count": "$count scheduled"
},
"view_all": "View all", "view_all": "View all",
"insight_lightbulb": "Save $amount/month", "insight_lightbulb": "Save $amount/month",
"insight_tip": "Book 48hrs ahead for better rates" "insight_tip": "Book 48hrs ahead for better rates"
@@ -237,6 +245,14 @@
"scan_button": "Scan NFC Tag", "scan_button": "Scan NFC Tag",
"tag_identified": "Tag Identified", "tag_identified": "Tag Identified",
"assign_button": "Assign Tag" "assign_button": "Assign Tag"
},
"delete_dialog": {
"title": "Confirm Hub Deletion",
"message": "Are you sure you want to delete \"$hubName\"?",
"undo_warning": "This action cannot be undone.",
"dependency_warning": "Note that if there are any shifts/orders assigned to this hub we shouldn't be able to delete the hub.",
"cancel": "Cancel",
"delete": "Delete"
} }
}, },
"client_create_order": { "client_create_order": {
@@ -337,14 +353,26 @@
"cancelled": "CANCELLED", "cancelled": "CANCELLED",
"get_direction": "Get direction", "get_direction": "Get direction",
"total": "Total", "total": "Total",
"hrs": "HRS", "hrs": "Hrs",
"workers": "$count workers", "workers": "$count workers",
"clock_in": "CLOCK IN", "clock_in": "CLOCK IN",
"clock_out": "CLOCK OUT", "clock_out": "CLOCK OUT",
"coverage": "Coverage", "coverage": "Coverage",
"workers_label": "$filled/$needed Workers", "workers_label": "$filled/$needed Workers",
"confirmed_workers": "Workers Confirmed", "confirmed_workers": "Workers Confirmed",
"no_workers": "No workers confirmed yet." "no_workers": "No workers confirmed yet.",
"today": "Today",
"tomorrow": "Tomorrow",
"workers_needed": "$count Workers Needed",
"all_confirmed": "All Workers Confirmed",
"confirmed_workers_title": "CONFIRMED WORKERS",
"message_all": "Message All",
"show_more_workers": "Show $count more workers",
"checked_in": "Checked In",
"call_dialog": {
"title": "Call",
"message": "Do you want to call $phone?"
}
} }
}, },
"client_billing": { "client_billing": {
@@ -498,6 +526,10 @@
"menu_items": { "menu_items": {
"personal_info": "Personal Info", "personal_info": "Personal Info",
"emergency_contact": "Emergency Contact", "emergency_contact": "Emergency Contact",
"emergency_contact_page": {
"save_success": "Emergency contacts saved successfully",
"save_continue": "Save & Continue"
},
"experience": "Experience", "experience": "Experience",
"attire": "Attire", "attire": "Attire",
"documents": "Documents", "documents": "Documents",
@@ -853,6 +885,7 @@
}, },
"staff_certificates": { "staff_certificates": {
"title": "Certificates", "title": "Certificates",
"error_loading": "Error loading certificates",
"progress": { "progress": {
"title": "Your Progress", "title": "Your Progress",
"verified_count": "$completed of $total verified", "verified_count": "$completed of $total verified",
@@ -966,6 +999,14 @@
"est_total": "Est. Total", "est_total": "Est. Total",
"hours_label": "$count hours", "hours_label": "$count hours",
"location": "LOCATION", "location": "LOCATION",
"tbd": "TBD",
"get_direction": "Get direction",
"break_title": "BREAK",
"paid": "Paid",
"unpaid": "Unpaid",
"min": "min",
"hourly_rate": "Hourly Rate",
"hours": "Hours",
"open_in_maps": "Open in Maps", "open_in_maps": "Open in Maps",
"job_description": "JOB DESCRIPTION", "job_description": "JOB DESCRIPTION",
"cancel_shift": "CANCEL SHIFT", "cancel_shift": "CANCEL SHIFT",
@@ -988,6 +1029,41 @@
"applying_dialog": { "applying_dialog": {
"title": "Applying" "title": "Applying"
} }
},
"card": {
"just_now": "Just now",
"assigned": "Assigned $time ago",
"accept_shift": "Accept shift",
"decline_shift": "Decline shift"
},
"my_shifts_tab": {
"confirm_dialog": {
"title": "Accept Shift",
"message": "Are you sure you want to accept this shift?",
"success": "Shift confirmed!"
},
"decline_dialog": {
"title": "Decline Shift",
"message": "Are you sure you want to decline this shift? This action cannot be undone.",
"success": "Shift declined."
},
"sections": {
"awaiting": "Awaiting Confirmation",
"cancelled": "Cancelled Shifts",
"confirmed": "Confirmed Shifts"
},
"empty": {
"title": "No shifts this week",
"subtitle": "Try finding new jobs in the Find tab"
},
"date": {
"today": "Today",
"tomorrow": "Tomorrow"
},
"card": {
"cancelled": "CANCELLED",
"compensation": "• 4hr compensation"
}
} }
}, },
"staff_time_card": { "staff_time_card": {

View File

@@ -148,9 +148,17 @@
"edit_mode_active": "Modo Edición Activo", "edit_mode_active": "Modo Edición Activo",
"drag_instruction": "Arrastra para reordenar, cambia la visibilidad", "drag_instruction": "Arrastra para reordenar, cambia la visibilidad",
"reset": "Restablecer", "reset": "Restablecer",
"todays_coverage": "COBERTURA DE HOY",
"percent_covered": "$percent% Cubierto",
"metric_needed": "Necesario", "metric_needed": "Necesario",
"metric_filled": "Lleno", "metric_filled": "Lleno",
"metric_open": "Abierto", "metric_open": "Abierto",
"spending": {
"this_week": "Esta Semana",
"next_7_days": "Próximos 7 Días",
"shifts_count": "$count turnos",
"scheduled_count": "$count programados"
},
"view_all": "Ver todo", "view_all": "Ver todo",
"insight_lightbulb": "Ahorra $amount/mes", "insight_lightbulb": "Ahorra $amount/mes",
"insight_tip": "Reserva con 48h de antelación para mejores tarifas" "insight_tip": "Reserva con 48h de antelación para mejores tarifas"
@@ -237,6 +245,14 @@
"scan_button": "Escanear Etiqueta NFC", "scan_button": "Escanear Etiqueta NFC",
"tag_identified": "Etiqueta Identificada", "tag_identified": "Etiqueta Identificada",
"assign_button": "Asignar Etiqueta" "assign_button": "Asignar Etiqueta"
},
"delete_dialog": {
"title": "Confirmar eliminación de Hub",
"message": "¿Estás seguro de que quieres eliminar \"$hubName\"?",
"undo_warning": "Esta acción no se puede deshacer.",
"dependency_warning": "Ten en cuenta que si hay turnos/órdenes asignados a este hub no deberíamos poder eliminarlo.",
"cancel": "Cancelar",
"delete": "Eliminar"
} }
}, },
"client_create_order": { "client_create_order": {
@@ -337,14 +353,26 @@
"cancelled": "CANCELADO", "cancelled": "CANCELADO",
"get_direction": "Obtener dirección", "get_direction": "Obtener dirección",
"total": "Total", "total": "Total",
"hrs": "HRS", "hrs": "Hrs",
"workers": "$count trabajadores", "workers": "$count trabajadores",
"clock_in": "ENTRADA", "clock_in": "ENTRADA",
"clock_out": "SALIDA", "clock_out": "SALIDA",
"coverage": "Cobertura", "coverage": "Cobertura",
"workers_label": "$filled/$needed Trabajadores", "workers_label": "$filled/$needed Trabajadores",
"confirmed_workers": "Trabajadores Confirmados", "confirmed_workers": "Trabajadores Confirmados",
"no_workers": "Ningún trabajador confirmado aún." "no_workers": "Ningún trabajador confirmado aún.",
"today": "Hoy",
"tomorrow": "Mañana",
"workers_needed": "$count Trabajadores Necesarios",
"all_confirmed": "Todos los trabajadores confirmados",
"confirmed_workers_title": "TRABAJADORES CONFIRMADOS",
"message_all": "Mensaje a todos",
"show_more_workers": "Mostrar $count trabajadores más",
"checked_in": "Registrado",
"call_dialog": {
"title": "Llamar",
"message": "¿Quieres llamar a $phone?"
}
} }
}, },
"client_billing": { "client_billing": {
@@ -498,6 +526,10 @@
"menu_items": { "menu_items": {
"personal_info": "Información Personal", "personal_info": "Información Personal",
"emergency_contact": "Contacto de Emergencia", "emergency_contact": "Contacto de Emergencia",
"emergency_contact_page": {
"save_success": "Contactos de emergencia guardados con éxito",
"save_continue": "Guardar y Continuar"
},
"experience": "Experiencia", "experience": "Experiencia",
"attire": "Vestimenta", "attire": "Vestimenta",
"documents": "Documentos", "documents": "Documentos",
@@ -853,6 +885,7 @@
}, },
"staff_certificates": { "staff_certificates": {
"title": "Certificados", "title": "Certificados",
"error_loading": "Error al cargar certificados",
"progress": { "progress": {
"title": "Tu Progreso", "title": "Tu Progreso",
"verified_count": "$completed de $total verificados", "verified_count": "$completed de $total verificados",
@@ -966,6 +999,14 @@
"est_total": "Total est.", "est_total": "Total est.",
"hours_label": "$count horas", "hours_label": "$count horas",
"location": "UBICACIÓN", "location": "UBICACIÓN",
"tbd": "TBD",
"get_direction": "Obtener dirección",
"break_title": "DESCANSO",
"paid": "Pagado",
"unpaid": "No pagado",
"min": "min",
"hourly_rate": "Tarifa por hora",
"hours": "Horas",
"open_in_maps": "Abrir en Mapas", "open_in_maps": "Abrir en Mapas",
"job_description": "DESCRIPCIÓN DEL TRABAJO", "job_description": "DESCRIPCIÓN DEL TRABAJO",
"cancel_shift": "CANCELAR TURNO", "cancel_shift": "CANCELAR TURNO",
@@ -988,6 +1029,41 @@
"applying_dialog": { "applying_dialog": {
"title": "Solicitando" "title": "Solicitando"
} }
},
"card": {
"just_now": "Recién",
"assigned": "Asignado hace $time",
"accept_shift": "Aceptar turno",
"decline_shift": "Rechazar turno"
},
"my_shifts_tab": {
"confirm_dialog": {
"title": "Aceptar Turno",
"message": "¿Estás seguro de que quieres aceptar este turno?",
"success": "¡Turno confirmado!"
},
"decline_dialog": {
"title": "Rechazar Turno",
"message": "¿Estás seguro de que quieres rechazar este turno? Esta acción no se puede deshacer.",
"success": "Turno rechazado."
},
"sections": {
"awaiting": "Esperando Confirmación",
"cancelled": "Turnos Cancelados",
"confirmed": "Turnos Confirmados"
},
"empty": {
"title": "Sin turnos esta semana",
"subtitle": "Intenta buscar nuevos trabajos en la pestaña Buscar"
},
"date": {
"today": "Hoy",
"tomorrow": "Mañana"
},
"card": {
"cancelled": "CANCELADO",
"compensation": "• Compensación de 4h"
}
} }
}, },
"staff_time_card": { "staff_time_card": {

View File

@@ -3,7 +3,6 @@
/// This package provides mock implementations of domain repository interfaces /// This package provides mock implementations of domain repository interfaces
/// for development and testing purposes. /// for development and testing purposes.
/// ///
/// TODO: These mocks currently do not implement any specific interfaces.
/// They will implement interfaces defined in feature packages once those are created. /// They will implement interfaces defined in feature packages once those are created.
library; library;
@@ -13,6 +12,8 @@ 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/mixins/session_handler_mixin.dart';
export 'src/session/staff_session_store.dart'; export 'src/session/staff_session_store.dart';
export 'src/mixins/data_error_handler.dart'; export 'src/services/mixins/data_error_handler.dart';

View File

@@ -1,10 +1,10 @@
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'services/data_connect_service.dart';
/// A module that provides Data Connect dependencies. /// A module that provides Data Connect dependencies.
class DataConnectModule extends Module { class DataConnectModule extends Module {
@override @override
void exportedBinds(Injector i) { void exportedBinds(Injector i) {
// No mock bindings anymore. i.addInstance<DataConnectService>(DataConnectService.instance);
// Real repositories are instantiated in their feature modules.
} }
} }

View File

@@ -0,0 +1,197 @@
import 'dart:async';
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:flutter/material.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../krow_data_connect.dart' as dc;
import 'mixins/data_error_handler.dart';
import 'mixins/session_handler_mixin.dart';
/// A centralized service for interacting with Firebase Data Connect.
///
/// This service provides common utilities and context management for all repositories.
class DataConnectService with DataErrorHandler, SessionHandlerMixin {
DataConnectService._();
/// The singleton instance of the [DataConnectService].
static final DataConnectService instance = DataConnectService._();
/// The Data Connect connector used for data operations.
final dc.ExampleConnector connector = dc.ExampleConnector.instance;
/// The Firebase Auth instance.
firebase_auth.FirebaseAuth get auth => _auth;
final firebase_auth.FirebaseAuth _auth = firebase_auth.FirebaseAuth.instance;
/// Cache for the current staff ID to avoid redundant lookups.
String? _cachedStaffId;
/// Cache for the current business ID to avoid redundant lookups.
String? _cachedBusinessId;
/// Gets the current staff ID from session store or persistent storage.
Future<String> getStaffId() async {
// 1. Check Session Store
final dc.StaffSession? session = dc.StaffSessionStore.instance.session;
if (session?.staff?.id != null) {
return session!.staff!.id;
}
// 2. Check Cache
if (_cachedStaffId != null) return _cachedStaffId!;
// 3. Fetch from Data Connect using Firebase UID
final firebase_auth.User? user = _auth.currentUser;
if (user == null) {
throw const NotAuthenticatedException(
technicalMessage: 'User is not authenticated',
);
}
try {
final fdc.QueryResult<
dc.GetStaffByUserIdData,
dc.GetStaffByUserIdVariables
>
response = await executeProtected(
() => connector.getStaffByUserId(userId: user.uid).execute(),
);
if (response.data.staffs.isNotEmpty) {
_cachedStaffId = response.data.staffs.first.id;
return _cachedStaffId!;
}
} catch (e) {
throw Exception('Failed to fetch staff ID from Data Connect: $e');
}
// 4. Fallback (should ideally not happen if DB is seeded)
return user.uid;
}
/// Gets the current business ID from session store or persistent storage.
Future<String> getBusinessId() async {
// 1. Check Session Store
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
if (session?.business?.id != null) {
return session!.business!.id;
}
// 2. Check Cache
if (_cachedBusinessId != null) return _cachedBusinessId!;
// 3. Fetch from Data Connect using Firebase UID
final firebase_auth.User? user = _auth.currentUser;
if (user == null) {
throw const NotAuthenticatedException(
technicalMessage: 'User is not authenticated',
);
}
try {
final fdc.QueryResult<
dc.GetBusinessesByUserIdData,
dc.GetBusinessesByUserIdVariables
>
response = await executeProtected(
() => connector.getBusinessesByUserId(userId: user.uid).execute(),
);
if (response.data.businesses.isNotEmpty) {
_cachedBusinessId = response.data.businesses.first.id;
return _cachedBusinessId!;
}
} catch (e) {
throw Exception('Failed to fetch business ID from Data Connect: $e');
}
// 4. Fallback (should ideally not happen if DB is seeded)
return user.uid;
}
/// Converts a Data Connect timestamp/string/json to a [DateTime].
DateTime? toDateTime(dynamic t) {
if (t == null) return null;
DateTime? dt;
if (t is fdc.Timestamp) {
dt = t.toDateTime();
} else if (t is String) {
dt = DateTime.tryParse(t);
} else {
try {
dt = DateTime.tryParse(t.toJson() as String);
} catch (_) {
try {
dt = DateTime.tryParse(t.toString());
} catch (e) {
dt = null;
}
}
}
if (dt != null) {
return DateTimeUtils.toDeviceTime(dt);
}
return null;
}
/// Converts a [DateTime] to a Firebase Data Connect [Timestamp].
fdc.Timestamp toTimestamp(DateTime dateTime) {
final DateTime utc = dateTime.toUtc();
final int seconds = utc.millisecondsSinceEpoch ~/ 1000;
final int nanoseconds = (utc.microsecondsSinceEpoch % 1000000) * 1000;
return fdc.Timestamp(nanoseconds, seconds);
}
// --- 3. Unified Execution ---
// Repositories call this to benefit from centralized error handling/logging
Future<T> run<T>(
Future<T> Function() action, {
bool requiresAuthentication = true,
}) async {
if (requiresAuthentication && auth.currentUser == null) {
throw const NotAuthenticatedException(
technicalMessage: 'User must be authenticated to perform this action',
);
}
return executeProtected(() async {
// Ensure session token is valid and refresh if needed
await ensureSessionValid();
return action();
});
}
/// Clears the internal cache (e.g., on logout).
void clearCache() {
_cachedStaffId = null;
_cachedBusinessId = null;
}
/// Handle session sign-out by clearing caches.
void handleSignOut() {
clearCache();
}
@override
Future<String?> fetchUserRole(String userId) async {
try {
final fdc.QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables>
response = await executeProtected(
() => connector.getUserById(id: userId).execute(),
);
return response.data.user?.userRole;
} catch (e) {
debugPrint('Failed to fetch user role: $e');
return null;
}
}
/// Dispose all resources (call on app shutdown).
Future<void> dispose() async {
await disposeSessionHandler();
}
}

View File

@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
/// Mixin to handle Data Layer errors and map them to Domain Failures. /// Mixin to handle Data Layer errors and map them to Domain Failures.
@@ -19,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) {
@@ -31,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') ||
@@ -55,6 +70,7 @@ 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());
} }
@@ -62,7 +78,7 @@ mixin DataErrorHandler {
if (e is AppException) rethrow; if (e is AppException) rethrow;
// Debugging: Log unexpected errors // Debugging: Log unexpected errors
print('DataErrorHandler: Unhandled exception caught: $e'); debugPrint('DataErrorHandler: Unhandled exception caught: $e');
throw UnknownException(technicalMessage: e.toString()); throw UnknownException(technicalMessage: e.toString());
} }

View File

@@ -0,0 +1,255 @@
import 'dart:async';
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
import 'package:flutter/cupertino.dart';
/// Enum representing the current session state.
enum SessionStateType { loading, authenticated, unauthenticated, error }
/// Data class for session state.
class SessionState {
/// Creates a [SessionState].
SessionState({required this.type, this.userId, this.errorMessage});
/// Creates a loading state.
factory SessionState.loading() =>
SessionState(type: SessionStateType.loading);
/// Creates an authenticated state.
factory SessionState.authenticated({required String userId}) =>
SessionState(type: SessionStateType.authenticated, userId: userId);
/// Creates an unauthenticated state.
factory SessionState.unauthenticated() =>
SessionState(type: SessionStateType.unauthenticated);
/// Creates an error state.
factory SessionState.error(String message) =>
SessionState(type: SessionStateType.error, errorMessage: message);
/// The type of session state.
final SessionStateType type;
/// The current user ID (if authenticated).
final String? userId;
/// Error message (if error occurred).
final String? errorMessage;
@override
String toString() =>
'SessionState(type: $type, userId: $userId, error: $errorMessage)';
}
/// Mixin for handling Firebase Auth session management, token refresh, and state emissions.
mixin SessionHandlerMixin {
/// Stream controller for session state changes.
final StreamController<SessionState> _sessionStateController =
StreamController<SessionState>.broadcast();
/// Last emitted session state (for late subscribers).
SessionState? _lastSessionState;
/// Public stream for listening to session state changes.
/// Late subscribers will immediately receive the last emitted state.
Stream<SessionState> get onSessionStateChanged {
// Create a custom stream that emits the last state before forwarding new events
return _createStreamWithLastState();
}
/// Creates a stream that emits the last state before subscribing to new events.
Stream<SessionState> _createStreamWithLastState() async* {
// If we have a last state, emit it immediately to late subscribers
if (_lastSessionState != null) {
yield _lastSessionState!;
}
// Then forward all subsequent events
yield* _sessionStateController.stream;
}
/// Last token refresh timestamp to avoid excessive checks.
DateTime? _lastTokenRefreshTime;
/// Subscription to auth state changes.
StreamSubscription<firebase_auth.User?>? _authStateSubscription;
/// Minimum interval between token refresh checks.
static const Duration _minRefreshCheckInterval = Duration(seconds: 2);
/// Time before token expiry to trigger a refresh.
static const Duration _refreshThreshold = Duration(minutes: 5);
/// Firebase Auth instance (to be provided by implementing class).
firebase_auth.FirebaseAuth get auth;
/// List of allowed roles for this app (to be set during initialization).
List<String> _allowedRoles = <String>[];
/// Initialize the auth state listener (call once on app startup).
void initializeAuthListener({List<String> allowedRoles = const <String>[]}) {
_allowedRoles = allowedRoles;
// Cancel any existing subscription first
_authStateSubscription?.cancel();
// Listen to Firebase auth state changes
_authStateSubscription = auth.authStateChanges().listen(
(firebase_auth.User? user) async {
if (user == null) {
_handleSignOut();
} else {
await _handleSignIn(user);
}
},
onError: (Object error) {
_emitSessionState(SessionState.error(error.toString()));
},
);
}
/// Validates if user has one of the allowed roles.
/// Returns true if user role is in allowed roles, false otherwise.
Future<bool> validateUserRole(
String userId,
List<String> allowedRoles,
) async {
try {
final String? userRole = await fetchUserRole(userId);
return userRole != null && allowedRoles.contains(userRole);
} catch (e) {
debugPrint('Failed to validate user role: $e');
return false;
}
}
/// Fetches user role from Data Connect.
/// To be implemented by concrete class.
Future<String?> fetchUserRole(String userId);
/// Ensures the Firebase auth token is valid and refreshes if needed.
/// Retries up to 3 times with exponential backoff before emitting error.
Future<void> ensureSessionValid() async {
final firebase_auth.User? user = auth.currentUser;
// No user = not authenticated, skip check
if (user == null) return;
// Optimization: Skip if we just checked within the last 2 seconds
final DateTime now = DateTime.now();
if (_lastTokenRefreshTime != null) {
final Duration timeSinceLastCheck = now.difference(
_lastTokenRefreshTime!,
);
if (timeSinceLastCheck < _minRefreshCheckInterval) {
return; // Skip redundant check
}
}
const int maxRetries = 3;
int retryCount = 0;
while (retryCount < maxRetries) {
try {
// Get token result (doesn't fetch from network unless needed)
final firebase_auth.IdTokenResult idToken = await user
.getIdTokenResult();
// Extract expiration time
final DateTime? expiryTime = idToken.expirationTime;
if (expiryTime == null) {
return; // Token info unavailable, proceed anyway
}
// Calculate time until expiry
final Duration timeUntilExpiry = expiryTime.difference(now);
// If token expires within 5 minutes, refresh it
if (timeUntilExpiry <= _refreshThreshold) {
await user.getIdTokenResult();
}
// Update last refresh check timestamp
_lastTokenRefreshTime = now;
return; // Success, exit retry loop
} catch (e) {
retryCount++;
debugPrint(
'Token validation error (attempt $retryCount/$maxRetries): $e',
);
// If we've exhausted retries, emit error
if (retryCount >= maxRetries) {
_emitSessionState(
SessionState.error(
'Token validation failed after $maxRetries attempts: $e',
),
);
return;
}
// Exponential backoff: 1s, 2s, 4s
final Duration backoffDuration = Duration(
seconds: 1 << (retryCount - 1), // 2^(retryCount-1)
);
debugPrint(
'Retrying token validation in ${backoffDuration.inSeconds}s',
);
await Future<void>.delayed(backoffDuration);
}
}
}
/// Handle user sign-in event.
Future<void> _handleSignIn(firebase_auth.User user) async {
try {
_emitSessionState(SessionState.loading());
// Validate role if allowed roles are specified
if (_allowedRoles.isNotEmpty) {
final bool isAuthorized = await validateUserRole(
user.uid,
_allowedRoles,
);
if (!isAuthorized) {
await auth.signOut();
_emitSessionState(SessionState.unauthenticated());
return;
}
}
// Get fresh token to validate session
final firebase_auth.IdTokenResult idToken = await user.getIdTokenResult();
if (idToken.expirationTime != null &&
DateTime.now().difference(idToken.expirationTime!) <
const Duration(minutes: 5)) {
// Token is expiring soon, refresh it
await user.getIdTokenResult();
}
// Emit authenticated state
_emitSessionState(SessionState.authenticated(userId: user.uid));
} catch (e) {
_emitSessionState(SessionState.error(e.toString()));
}
}
/// Handle user sign-out event.
void _handleSignOut() {
_emitSessionState(SessionState.unauthenticated());
}
/// Emit session state update.
void _emitSessionState(SessionState state) {
_lastSessionState = state;
if (!_sessionStateController.isClosed) {
_sessionStateController.add(state);
}
}
/// Dispose session handler resources.
Future<void> disposeSessionHandler() async {
await _authStateSubscription?.cancel();
await _sessionStateController.close();
}
}

View File

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

View File

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

View File

@@ -15,3 +15,6 @@ dependencies:
path: ../domain path: ../domain
flutter_modular: ^6.3.0 flutter_modular: ^6.3.0
firebase_data_connect: ^0.2.2+2 firebase_data_connect: ^0.2.2+2
firebase_core: ^4.4.0
firebase_auth: ^6.1.4
krow_core: ^0.0.1

View File

@@ -40,7 +40,7 @@ class UiTheme {
dividerTheme: const DividerThemeData( dividerTheme: const DividerThemeData(
color: UiColors.separatorPrimary, color: UiColors.separatorPrimary,
space: 1, space: 1,
thickness: 1, thickness: 0.5,
), ),
// Card Theme // Card Theme

View File

@@ -173,6 +173,14 @@ class UiTypography {
color: UiColors.textPrimary, color: UiColors.textPrimary,
); );
/// Headline 1 Bold - Font: Instrument Sans, Size: 26, Height: 1.5 (#121826)
static final TextStyle headline1b = _primaryBase.copyWith(
fontWeight: FontWeight.w600,
fontSize: 26,
height: 1.5,
color: UiColors.textPrimary,
);
/// Headline 2 Medium - Font: Instrument Sans, Size: 20, Height: 1.5 (#121826) /// Headline 2 Medium - Font: Instrument Sans, Size: 20, Height: 1.5 (#121826)
static final TextStyle headline2m = _primaryBase.copyWith( static final TextStyle headline2m = _primaryBase.copyWith(
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,

View File

@@ -30,6 +30,8 @@ export 'src/entities/events/work_session.dart';
// Shifts // Shifts
export 'src/entities/shifts/shift.dart'; export 'src/entities/shifts/shift.dart';
export 'src/adapters/shifts/shift_adapter.dart'; export 'src/adapters/shifts/shift_adapter.dart';
export 'src/entities/shifts/break/break.dart';
export 'src/adapters/shifts/break/break_adapter.dart';
// Orders & Requests // Orders & Requests
export 'src/entities/orders/order_type.dart'; export 'src/entities/orders/order_type.dart';
@@ -51,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';
@@ -66,7 +72,6 @@ export 'src/entities/ratings/business_staff_preference.dart';
// Staff Profile // Staff Profile
export 'src/entities/profile/emergency_contact.dart'; export 'src/entities/profile/emergency_contact.dart';
export 'src/entities/profile/bank_account.dart';
export 'src/entities/profile/accessibility.dart'; export 'src/entities/profile/accessibility.dart';
export 'src/entities/profile/schedule.dart'; export 'src/entities/profile/schedule.dart';

View File

@@ -0,0 +1,21 @@
import '../../../entities/financial/bank_account/business_bank_account.dart';
/// Adapter for [BusinessBankAccount] to map data layer values to domain entity.
class BusinessBankAccountAdapter {
/// Maps primitive values to [BusinessBankAccount].
static BusinessBankAccount fromPrimitives({
required String id,
required String bank,
required String last4,
required bool isPrimary,
DateTime? expiryTime,
}) {
return BusinessBankAccount(
id: id,
bankName: bank,
last4: last4,
isPrimary: isPrimary,
expiryTime: expiryTime,
);
}
}

View File

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

View File

@@ -0,0 +1,39 @@
import '../../../entities/shifts/break/break.dart';
/// Adapter for Break related data.
class BreakAdapter {
/// Maps break data to a Break entity.
///
/// [isPaid] whether the break is paid.
/// [breakTime] the string representation of the break duration (e.g., 'MIN_10', 'MIN_30').
static Break fromData({
required bool isPaid,
required String? breakTime,
}) {
return Break(
isBreakPaid: isPaid,
duration: _parseDuration(breakTime),
);
}
static BreakDuration _parseDuration(String? breakTime) {
if (breakTime == null) return BreakDuration.none;
switch (breakTime.toUpperCase()) {
case 'MIN_10':
return BreakDuration.ten;
case 'MIN_15':
return BreakDuration.fifteen;
case 'MIN_20':
return BreakDuration.twenty;
case 'MIN_30':
return BreakDuration.thirty;
case 'MIN_45':
return BreakDuration.fortyFive;
case 'MIN_60':
return BreakDuration.sixty;
default:
return BreakDuration.none;
}
}
}

View File

@@ -0,0 +1,27 @@
import 'package:equatable/equatable.dart';
/// Abstract base class for all types of bank accounts.
abstract class BankAccount extends Equatable {
/// Creates a [BankAccount].
const BankAccount({
required this.id,
required this.bankName,
required this.isPrimary,
this.last4,
});
/// Unique identifier.
final String id;
/// Name of the bank or provider.
final String bankName;
/// Whether this is the primary payment method.
final bool isPrimary;
/// Last 4 digits of the account/card.
final String? last4;
@override
List<Object?> get props => <Object?>[id, bankName, isPrimary, last4];
}

View File

@@ -0,0 +1,26 @@
import 'bank_account.dart';
/// Domain model representing a business bank account or payment method.
class BusinessBankAccount extends BankAccount {
/// Creates a [BusinessBankAccount].
const BusinessBankAccount({
required super.id,
required super.bankName,
required String last4,
required super.isPrimary,
this.expiryTime,
}) : super(last4: last4);
/// Expiration date if applicable.
final DateTime? expiryTime;
@override
List<Object?> get props => <Object?>[
...super.props,
expiryTime,
];
/// Getter for non-nullable last4 in Business context.
@override
String get last4 => super.last4!;
}

View File

@@ -0,0 +1,48 @@
import 'bank_account.dart';
/// Type of staff bank account.
enum StaffBankAccountType {
/// Checking account.
checking,
/// Savings account.
savings,
/// Other type.
other,
}
/// Domain entity representing a staff's bank account.
class StaffBankAccount extends BankAccount {
/// Creates a [StaffBankAccount].
const StaffBankAccount({
required super.id,
required this.userId,
required super.bankName,
required this.accountNumber,
required this.accountName,
required super.isPrimary,
super.last4,
this.sortCode,
this.type = StaffBankAccountType.checking,
});
/// User identifier.
final String userId;
/// Full account number.
final String accountNumber;
/// Name of the account holder.
final String accountName;
/// Sort code (optional).
final String? sortCode;
/// Account type.
final StaffBankAccountType type;
@override
List<Object?> get props =>
<Object?>[...super.props, userId, accountNumber, accountName, sortCode, type];
}

View File

@@ -1,53 +0,0 @@
import 'package:equatable/equatable.dart';
/// Account type (Checking, Savings, etc).
enum BankAccountType {
checking,
savings,
other,
}
/// Represents bank account details for payroll.
class BankAccount extends Equatable {
const BankAccount({
required this.id,
required this.userId,
required this.bankName,
required this.accountNumber,
required this.accountName,
this.sortCode,
this.type = BankAccountType.checking,
this.isPrimary = false,
this.last4,
});
/// Unique identifier.
final String id;
/// The [User] owning the account.
final String userId;
/// Name of the bank.
final String bankName;
/// Account number.
final String accountNumber;
/// Name on the account.
final String accountName;
/// Sort code (if applicable).
final String? sortCode;
/// Type of account.
final BankAccountType type;
/// Whether this is the primary account.
final bool isPrimary;
/// Last 4 digits.
final String? last4;
@override
List<Object?> get props => <Object?>[id, userId, bankName, accountNumber, accountName, sortCode, type, isPrimary, last4];
}

View File

@@ -0,0 +1,47 @@
import 'package:equatable/equatable.dart';
/// Enum representing common break durations in minutes.
enum BreakDuration {
/// No break.
none(0),
/// 10 minutes break.
ten(10),
/// 15 minutes break.
fifteen(15),
/// 20 minutes break.
twenty(20),
/// 30 minutes break.
thirty(30),
/// 45 minutes break.
fortyFive(45),
/// 60 minutes break.
sixty(60);
/// The duration in minutes.
final int minutes;
const BreakDuration(this.minutes);
}
/// Represents a break configuration for a shift.
class Break extends Equatable {
const Break({
required this.duration,
required this.isBreakPaid,
});
/// The duration of the break.
final BreakDuration duration;
/// Whether the break is paid or unpaid.
final bool isBreakPaid;
@override
List<Object?> get props => <Object?>[duration, isBreakPaid];
}

View File

@@ -1,4 +1,5 @@
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:krow_domain/src/entities/shifts/break/break.dart';
class Shift extends Equatable { class Shift extends Equatable {
final String id; final String id;
@@ -29,6 +30,7 @@ class Shift extends Equatable {
final String? roleId; final String? roleId;
final bool? hasApplied; final bool? hasApplied;
final double? totalValue; final double? totalValue;
final Break? breakInfo;
const Shift({ const Shift({
required this.id, required this.id,
@@ -59,48 +61,49 @@ class Shift extends Equatable {
this.roleId, this.roleId,
this.hasApplied, this.hasApplied,
this.totalValue, this.totalValue,
this.breakInfo,
}); });
@override @override
List<Object?> get props => [ List<Object?> get props => <Object?>[
id, id,
title, title,
clientName, clientName,
logoUrl, logoUrl,
hourlyRate, hourlyRate,
location, location,
locationAddress, locationAddress,
date, date,
startTime, startTime,
endTime, endTime,
createdDate, createdDate,
tipsAvailable, tipsAvailable,
travelTime, travelTime,
mealProvided, mealProvided,
parkingAvailable, parkingAvailable,
gasCompensation, gasCompensation,
description, description,
instructions, instructions,
managers, managers,
latitude, latitude,
longitude, longitude,
status, status,
durationDays, durationDays,
requiredSlots, requiredSlots,
filledSlots, filledSlots,
roleId, roleId,
hasApplied, hasApplied,
totalValue, totalValue,
]; breakInfo,
];
} }
class ShiftManager extends Equatable { class ShiftManager extends Equatable {
const ShiftManager({required this.name, required this.phone, this.avatar});
final String name; final String name;
final String phone; final String phone;
final String? avatar; final String? avatar;
const ShiftManager({required this.name, required this.phone, this.avatar});
@override @override
List<Object?> get props => [name, phone, avatar]; List<Object?> get props => <Object?>[name, phone, avatar];
} }

View File

@@ -1,6 +1,5 @@
library; library;
import 'package:firebase_auth/firebase_auth.dart' as firebase;
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 'package:krow_data_connect/krow_data_connect.dart';
@@ -11,6 +10,7 @@ import 'src/domain/usecases/sign_in_with_social_use_case.dart';
import 'src/domain/usecases/sign_out_use_case.dart'; import 'src/domain/usecases/sign_out_use_case.dart';
import 'src/domain/usecases/sign_up_with_email_use_case.dart'; import 'src/domain/usecases/sign_up_with_email_use_case.dart';
import 'src/presentation/blocs/client_auth_bloc.dart'; import 'src/presentation/blocs/client_auth_bloc.dart';
import 'src/presentation/pages/client_intro_page.dart';
import 'src/presentation/pages/client_get_started_page.dart'; import 'src/presentation/pages/client_get_started_page.dart';
import 'src/presentation/pages/client_sign_in_page.dart'; import 'src/presentation/pages/client_sign_in_page.dart';
import 'src/presentation/pages/client_sign_up_page.dart'; import 'src/presentation/pages/client_sign_up_page.dart';
@@ -28,12 +28,7 @@ class ClientAuthenticationModule extends Module {
@override @override
void binds(Injector i) { void binds(Injector i) {
// Repositories // Repositories
i.addLazySingleton<AuthRepositoryInterface>( i.addLazySingleton<AuthRepositoryInterface>(AuthRepositoryImpl.new);
() => AuthRepositoryImpl(
firebaseAuth: firebase.FirebaseAuth.instance,
dataConnect: ExampleConnector.instance,
),
);
// UseCases // UseCases
i.addLazySingleton( i.addLazySingleton(
@@ -60,7 +55,8 @@ class ClientAuthenticationModule extends Module {
@override @override
void routes(RouteManager r) { void routes(RouteManager r) {
r.child(ClientPaths.root, child: (_) => const ClientGetStartedPage()); r.child(ClientPaths.root, child: (_) => const ClientIntroPage());
r.child(ClientPaths.getStarted, child: (_) => const ClientGetStartedPage());
r.child(ClientPaths.signIn, child: (_) => const ClientSignInPage()); r.child(ClientPaths.signIn, child: (_) => const ClientSignInPage());
r.child(ClientPaths.signUp, child: (_) => const ClientSignUpPage()); r.child(ClientPaths.signUp, child: (_) => const ClientSignUpPage());
} }

View File

@@ -12,7 +12,6 @@ import 'package:krow_domain/krow_domain.dart'
AccountExistsException, AccountExistsException,
UserNotFoundException, UserNotFoundException,
UnauthorizedAppException, UnauthorizedAppException,
UnauthorizedAppException,
PasswordMismatchException, PasswordMismatchException,
NetworkException; NetworkException;
import 'package:krow_domain/krow_domain.dart' as domain; import 'package:krow_domain/krow_domain.dart' as domain;
@@ -23,18 +22,12 @@ import '../../domain/repositories/auth_repository_interface.dart';
/// ///
/// This implementation integrates with Firebase Authentication for user /// This implementation integrates with Firebase Authentication for user
/// 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 class AuthRepositoryImpl implements AuthRepositoryInterface {
with dc.DataErrorHandler
implements AuthRepositoryInterface {
/// Creates an [AuthRepositoryImpl] with the real dependencies. /// Creates an [AuthRepositoryImpl] with the real dependencies.
AuthRepositoryImpl({ AuthRepositoryImpl({dc.DataConnectService? service})
required firebase.FirebaseAuth firebaseAuth, : _service = service ?? dc.DataConnectService.instance;
required dc.ExampleConnector dataConnect,
}) : _firebaseAuth = firebaseAuth, final dc.DataConnectService _service;
_dataConnect = dataConnect;
final firebase.FirebaseAuth _firebaseAuth;
final dc.ExampleConnector _dataConnect;
@override @override
Future<domain.User> signInWithEmail({ Future<domain.User> signInWithEmail({
@@ -42,10 +35,8 @@ class AuthRepositoryImpl
required String password, required String password,
}) async { }) async {
try { try {
final firebase.UserCredential credential = await _firebaseAuth.signInWithEmailAndPassword( final firebase.UserCredential credential = await _service.auth
email: email, .signInWithEmailAndPassword(email: email, password: password);
password: password,
);
final firebase.User? firebaseUser = credential.user; final firebase.User? firebaseUser = credential.user;
if (firebaseUser == null) { if (firebaseUser == null) {
@@ -59,16 +50,13 @@ class AuthRepositoryImpl
fallbackEmail: firebaseUser.email ?? email, fallbackEmail: firebaseUser.email ?? email,
requireBusinessRole: true, requireBusinessRole: true,
); );
} on firebase.FirebaseAuthException catch (e) { } on firebase.FirebaseAuthException catch (e) {
if (e.code == 'invalid-credential' || e.code == 'wrong-password') { if (e.code == 'invalid-credential' || e.code == 'wrong-password') {
throw InvalidCredentialsException( throw InvalidCredentialsException(
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}',
@@ -77,9 +65,7 @@ class AuthRepositoryImpl
} on domain.AppException { } on domain.AppException {
rethrow; rethrow;
} catch (e) { } catch (e) {
throw SignInFailedException( throw SignInFailedException(technicalMessage: 'Unexpected error: $e');
technicalMessage: 'Unexpected error: $e',
);
} }
} }
@@ -94,10 +80,8 @@ class AuthRepositoryImpl
try { try {
// Step 1: Try to create Firebase Auth user // Step 1: Try to create Firebase Auth user
final firebase.UserCredential credential = await _firebaseAuth.createUserWithEmailAndPassword( final firebase.UserCredential credential = await _service.auth
email: email, .createUserWithEmailAndPassword(email: email, password: password);
password: password,
);
firebaseUser = credential.user; firebaseUser = credential.user;
if (firebaseUser == null) { if (firebaseUser == null) {
@@ -111,14 +95,12 @@ class AuthRepositoryImpl
firebaseUser: firebaseUser, firebaseUser: firebaseUser,
companyName: companyName, companyName: companyName,
email: email, email: email,
onBusinessCreated: (String businessId) => createdBusinessId = businessId, onBusinessCreated: (String businessId) =>
createdBusinessId = businessId,
); );
} 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(
@@ -127,9 +109,7 @@ class AuthRepositoryImpl
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}',
@@ -137,14 +117,18 @@ class AuthRepositoryImpl
} }
} on domain.AppException { } on domain.AppException {
// Rollback for our known exceptions // Rollback for our known exceptions
await _rollbackSignUp(firebaseUser: firebaseUser, businessId: createdBusinessId); await _rollbackSignUp(
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(firebaseUser: firebaseUser, businessId: createdBusinessId); await _rollbackSignUp(
throw SignUpFailedException( firebaseUser: firebaseUser,
technicalMessage: 'Unexpected error: $e', businessId: createdBusinessId,
); );
throw SignUpFailedException(technicalMessage: 'Unexpected error: $e');
} }
} }
@@ -164,14 +148,15 @@ class AuthRepositoryImpl
required String password, required String password,
required String companyName, required String companyName,
}) async { }) async {
developer.log('Email exists in Firebase, attempting sign-in: $email', name: 'AuthRepository'); developer.log(
'Email exists in Firebase, attempting sign-in: $email',
name: 'AuthRepository',
);
try { try {
// Try to sign in with the provided password // Try to sign in with the provided password
final firebase.UserCredential credential = await _firebaseAuth.signInWithEmailAndPassword( final firebase.UserCredential credential = await _service.auth
email: email, .signInWithEmailAndPassword(email: email, password: password);
password: password,
);
final firebase.User? firebaseUser = credential.user; final firebase.User? firebaseUser = credential.user;
if (firebaseUser == null) { if (firebaseUser == null) {
@@ -181,28 +166,40 @@ class AuthRepositoryImpl
} }
// 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 = await _checkBusinessUserExists(firebaseUser.uid); final bool hasBusinessAccount = await _checkBusinessUserExists(
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}', name: 'AuthRepository'); developer.log(
'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('Creating BUSINESS account for existing Firebase user: ${firebaseUser.uid}', name: 'AuthRepository'); developer.log(
'Creating BUSINESS account for existing Firebase user: ${firebaseUser.uid}',
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}', name: 'AuthRepository'); developer.log(
'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
@@ -226,9 +223,13 @@ class AuthRepositoryImpl
// 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', name: 'AuthRepository'); developer.log(
'Password mismatch or different provider for: $email',
name: 'AuthRepository',
);
throw PasswordMismatchException( throw PasswordMismatchException(
technicalMessage: 'Email $email: password mismatch or different auth provider', technicalMessage:
'Email $email: password mismatch or different auth provider',
); );
} }
@@ -236,9 +237,12 @@ class AuthRepositoryImpl
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 executeProtected(() => _dataConnect.getUserById(id: firebaseUserId).execute()); await _service.run(
() => _service.connector.getUserById(id: firebaseUserId).execute(),
);
final dc.GetUserByIdUser? user = response.data.user; final dc.GetUserByIdUser? user = response.data.user;
return user != null && user.userRole == 'BUSINESS'; return user != null &&
(user.userRole == 'BUSINESS' || user.userRole == 'BOTH');
} }
/// Creates Business and User entities in PostgreSQL for a Firebase user. /// Creates Business and User entities in PostgreSQL for a Firebase user.
@@ -250,29 +254,47 @@ class AuthRepositoryImpl
}) async { }) async {
// Create Business entity in PostgreSQL // Create Business entity in PostgreSQL
final OperationResult<dc.CreateBusinessData, dc.CreateBusinessVariables> createBusinessResponse = final OperationResult<dc.CreateBusinessData, dc.CreateBusinessVariables>
await executeProtected(() => _dataConnect.createBusiness( createBusinessResponse = await _service.run(
businessName: companyName, () => _service.connector
userId: firebaseUser.uid, .createBusiness(
rateGroup: dc.BusinessRateGroup.STANDARD, businessName: companyName,
status: dc.BusinessStatus.PENDING, userId: firebaseUser.uid,
).execute()); rateGroup: dc.BusinessRateGroup.STANDARD,
status: dc.BusinessStatus.PENDING,
)
.execute(),
);
final dc.CreateBusinessBusinessInsert businessData = createBusinessResponse.data.business_insert; final dc.CreateBusinessBusinessInsert businessData =
createBusinessResponse.data.business_insert;
onBusinessCreated(businessData.id); onBusinessCreated(businessData.id);
// Create User entity in PostgreSQL // Check if User entity already exists in PostgreSQL
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> userResult =
await _service.run(
() => _service.connector.getUserById(id: firebaseUser.uid).execute(),
);
final dc.GetUserByIdUser? existingUser = userResult.data.user;
final OperationResult<dc.CreateUserData, dc.CreateUserVariables> createUserResponse = if (existingUser != null) {
await executeProtected(() => _dataConnect.createUser( // User exists (likely in another app like STAFF). Update role to BOTH.
id: firebaseUser.uid, await _service.run(
role: dc.UserBaseRole.USER, () => _service.connector
) .updateUser(id: firebaseUser.uid)
.email(email) .userRole('BOTH')
.userRole('BUSINESS') .execute(),
.execute()); );
} else {
final dc.CreateUserUserInsert newUserData = createUserResponse.data.user_insert; // Create new User entity in PostgreSQL
await _service.run(
() => _service.connector
.createUser(id: firebaseUser.uid, role: dc.UserBaseRole.USER)
.email(email)
.userRole('BUSINESS')
.execute(),
);
}
return _getUserProfile( return _getUserProfile(
firebaseUserId: firebaseUser.uid, firebaseUserId: firebaseUser.uid,
@@ -288,7 +310,7 @@ class AuthRepositoryImpl
// Delete business first (if created) // Delete business first (if created)
if (businessId != null) { if (businessId != null) {
try { try {
await _dataConnect.deleteBusiness(id: businessId).execute(); await _service.connector.deleteBusiness(id: businessId).execute();
} catch (_) { } catch (_) {
// Log but don't throw - we're already in error recovery // Log but don't throw - we're already in error recovery
} }
@@ -306,8 +328,9 @@ class AuthRepositoryImpl
@override @override
Future<void> signOut() async { Future<void> signOut() async {
try { try {
await _firebaseAuth.signOut(); await _service.auth.signOut();
dc.ClientSessionStore.instance.clear(); dc.ClientSessionStore.instance.clear();
_service.clearCache();
} catch (e) { } catch (e) {
throw Exception('Error signing out: ${e.toString()}'); throw Exception('Error signing out: ${e.toString()}');
} }
@@ -315,7 +338,9 @@ class AuthRepositoryImpl
@override @override
Future<domain.User> signInWithSocial({required String provider}) { Future<domain.User> signInWithSocial({required String provider}) {
throw UnimplementedError('Social authentication with $provider is not yet implemented.'); throw UnimplementedError(
'Social authentication with $provider is not yet implemented.',
);
} }
Future<domain.User> _getUserProfile({ Future<domain.User> _getUserProfile({
@@ -324,18 +349,25 @@ class AuthRepositoryImpl
bool requireBusinessRole = false, bool requireBusinessRole = false,
}) async { }) async {
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response = final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response =
await executeProtected(() => _dataConnect.getUserById(id: firebaseUserId).execute()); await _service.run(
() => _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(
technicalMessage: 'Firebase UID $firebaseUserId not found in users table', technicalMessage:
'Firebase UID $firebaseUserId not found in users table',
); );
} }
if (requireBusinessRole && user.userRole != 'BUSINESS') { if (requireBusinessRole &&
await _firebaseAuth.signOut(); user.userRole != 'BUSINESS' &&
user.userRole != 'BOTH') {
await _service.auth.signOut();
dc.ClientSessionStore.instance.clear(); dc.ClientSessionStore.instance.clear();
_service.clearCache();
throw UnauthorizedAppException( throw UnauthorizedAppException(
technicalMessage: 'User role is ${user.userRole}, expected BUSINESS', technicalMessage:
'User role is ${user.userRole}, expected BUSINESS or BOTH',
); );
} }
@@ -352,18 +384,22 @@ class AuthRepositoryImpl
role: user.role.stringValue, role: user.role.stringValue,
); );
final QueryResult<dc.GetBusinessesByUserIdData, dc.GetBusinessesByUserIdVariables> businessResponse = final QueryResult<
await executeProtected(() => _dataConnect.getBusinessesByUserId( dc.GetBusinessesByUserIdData,
userId: firebaseUserId, dc.GetBusinessesByUserIdVariables
).execute()); >
final dc.GetBusinessesByUserIdBusinesses? business = businessResponse.data.businesses.isNotEmpty businessResponse = await _service.run(
() => _service.connector
.getBusinessesByUserId(userId: firebaseUserId)
.execute(),
);
final dc.GetBusinessesByUserIdBusinesses? business =
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(

View File

@@ -27,94 +27,108 @@ class ClientGetStartedPage extends StatelessWidget {
), ),
SafeArea( SafeArea(
child: Column( child: LayoutBuilder(
children: <Widget>[ builder: (BuildContext context, BoxConstraints constraints) {
const SizedBox(height: UiConstants.space10), return SingleChildScrollView(
// Logo child: ConstrainedBox(
Center( constraints: BoxConstraints(
child: Image.asset( minHeight: constraints.maxHeight,
UiImageAssets.logoBlue, ),
height: 40, child: IntrinsicHeight(
fit: BoxFit.contain, child: Column(
children: <Widget>[
const SizedBox(height: UiConstants.space10),
// Logo
Center(
child: Image.asset(
UiImageAssets.logoBlue,
height: 40,
fit: BoxFit.contain,
),
),
const Spacer(),
// Content Cards Area (Keeping prototype layout)
Container(
height: 300,
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space6,
),
child: Stack(
children: <Widget>[
// Representative cards from prototype
Positioned(
top: 20,
left: 0,
right: 20,
child: _ShiftOrderCard(),
),
Positioned(
bottom: 40,
right: 0,
left: 40,
child: _WorkerProfileCard(),
),
Positioned(
top: 60,
right: 10,
child: _CalendarCard(),
),
],
),
),
const Spacer(),
// Bottom Content
Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space6,
vertical: UiConstants.space10,
),
child: Column(
children: <Widget>[
Text(
t.client_authentication.get_started_page.title,
textAlign: TextAlign.center,
style: UiTypography.displayM,
),
const SizedBox(height: UiConstants.space3),
Text(
t.client_authentication.get_started_page
.subtitle,
textAlign: TextAlign.center,
style: UiTypography.body2r.textSecondary,
),
const SizedBox(height: UiConstants.space8),
// Sign In Button
UiButton.primary(
text: t.client_authentication.get_started_page
.sign_in_button,
onPressed: () => Modular.to.toClientSignIn(),
fullWidth: true,
),
const SizedBox(height: UiConstants.space3),
// Create Account Button
UiButton.secondary(
text: t.client_authentication.get_started_page
.create_account_button,
onPressed: () => Modular.to.toClientSignUp(),
fullWidth: true,
),
],
),
),
],
),
),
), ),
), );
},
const Spacer(),
// Content Cards Area (Keeping prototype layout)
Container(
height: 300,
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space6,
),
child: Stack(
children: <Widget>[
// Representative cards from prototype
Positioned(
top: 20,
left: 0,
right: 20,
child: _ShiftOrderCard(),
),
Positioned(
bottom: 40,
right: 0,
left: 40,
child: _WorkerProfileCard(),
),
Positioned(top: 60, right: 10, child: _CalendarCard()),
],
),
),
const Spacer(),
// Bottom Content
Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space6,
vertical: UiConstants.space10,
),
child: Column(
children: <Widget>[
Text(
t.client_authentication.get_started_page.title,
textAlign: TextAlign.center,
style: UiTypography.displayM,
),
const SizedBox(height: UiConstants.space3),
Text(
t.client_authentication.get_started_page.subtitle,
textAlign: TextAlign.center,
style: UiTypography.body2r.textSecondary,
),
const SizedBox(height: UiConstants.space8),
// Sign In Button
UiButton.primary(
text: t
.client_authentication
.get_started_page
.sign_in_button,
onPressed: () => Modular.to.toClientSignIn(),
fullWidth: true,
),
const SizedBox(height: UiConstants.space3),
// Create Account Button
UiButton.secondary(
text: t
.client_authentication
.get_started_page
.create_account_button,
onPressed: () => Modular.to.toClientSignUp(),
fullWidth: true,
),
],
),
),
],
), ),
), ),
], ],

View File

@@ -0,0 +1,19 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
class ClientIntroPage extends StatelessWidget {
const ClientIntroPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
body: Center(
child: Image.asset(
UiImageAssets.logoBlue,
width: 120,
),
),
);
}
}

View File

@@ -41,7 +41,7 @@ class ClientSignUpPage extends StatelessWidget {
final TranslationsClientAuthenticationSignUpPageEn i18n = t.client_authentication.sign_up_page; final TranslationsClientAuthenticationSignUpPageEn i18n = t.client_authentication.sign_up_page;
final ClientAuthBloc authBloc = Modular.get<ClientAuthBloc>(); final ClientAuthBloc authBloc = Modular.get<ClientAuthBloc>();
return BlocProvider.value( return BlocProvider<ClientAuthBloc>.value(
value: authBloc, value: authBloc,
child: BlocConsumer<ClientAuthBloc, ClientAuthState>( child: BlocConsumer<ClientAuthBloc, ClientAuthState>(
listener: (BuildContext context, ClientAuthState state) { listener: (BuildContext context, ClientAuthState state) {

View File

@@ -1,9 +1,9 @@
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 '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';
@@ -19,13 +19,10 @@ class BillingModule extends Module {
// Repositories // Repositories
i.addSingleton<BillingRepository>( i.addSingleton<BillingRepository>(BillingRepositoryImpl.new);
() => BillingRepositoryImpl(
dataConnect: ExampleConnector.instance,
),
);
// 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);
@@ -35,6 +32,7 @@ class BillingModule extends Module {
// BLoCs // BLoCs
i.addSingleton<BillingBloc>( i.addSingleton<BillingBloc>(
() => BillingBloc( () => BillingBloc(
getBankAccounts: i.get<GetBankAccountsUseCase>(),
getCurrentBillAmount: i.get<GetCurrentBillAmountUseCase>(), getCurrentBillAmount: i.get<GetCurrentBillAmountUseCase>(),
getSavingsAmount: i.get<GetSavingsAmountUseCase>(), getSavingsAmount: i.get<GetSavingsAmountUseCase>(),
getPendingInvoices: i.get<GetPendingInvoicesUseCase>(), getPendingInvoices: i.get<GetPendingInvoicesUseCase>(),

View File

@@ -6,88 +6,95 @@ import '../../domain/repositories/billing_repository.dart';
/// Implementation of [BillingRepository] in the Data layer. /// Implementation of [BillingRepository] in the Data layer.
/// ///
/// This class is responsible for retrieving billing data from the [FinancialRepositoryMock] /// This class is responsible for retrieving billing data from the
/// (which represents the Data Connect layer) and mapping it to Domain entities. /// Data Connect layer and mapping it to Domain entities.
/// class BillingRepositoryImpl implements BillingRepository {
/// It strictly adheres to the Clean Architecture data layer responsibilities:
/// - No business logic (except necessary data transformation/filtering).
/// - Delegates to data sources.
class BillingRepositoryImpl
with data_connect.DataErrorHandler
implements BillingRepository {
/// Creates a [BillingRepositoryImpl]. /// Creates a [BillingRepositoryImpl].
///
/// Requires the [financialRepository] to fetch financial data.
BillingRepositoryImpl({ BillingRepositoryImpl({
required data_connect.ExampleConnector dataConnect, data_connect.DataConnectService? service,
}) : _dataConnect = dataConnect; }) : _service = service ?? data_connect.DataConnectService.instance;
final data_connect.ExampleConnector _dataConnect; 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
@override
Future<double> getCurrentBillAmount() async { Future<double> getCurrentBillAmount() async {
final String? businessId = return _service.run(() async {
data_connect.ClientSessionStore.instance.session?.business?.id; final String businessId = await _service.getBusinessId();
if (businessId == null || businessId.isEmpty) {
return 0.0;
}
final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData, data_connect.ListInvoicesByBusinessIdVariables> result = await executeProtected(() => _dataConnect final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData,
.listInvoicesByBusinessId(businessId: businessId) data_connect.ListInvoicesByBusinessIdVariables> result =
.execute()); await _service.connector
.listInvoicesByBusinessId(businessId: businessId)
.execute();
return result.data.invoices return result.data.invoices
.map(_mapInvoice) .map(_mapInvoice)
.where((Invoice i) => i.status == InvoiceStatus.open) .where((Invoice i) => i.status == InvoiceStatus.open)
.fold<double>( .fold<double>(
0.0, 0.0,
(double sum, Invoice item) => sum + item.totalAmount, (double sum, Invoice item) => sum + item.totalAmount,
); );
});
} }
/// Fetches the history of paid invoices. /// Fetches the history of paid invoices.
@override @override
Future<List<Invoice>> getInvoiceHistory() async { Future<List<Invoice>> getInvoiceHistory() async {
final String? businessId = return _service.run(() async {
data_connect.ClientSessionStore.instance.session?.business?.id; final String businessId = await _service.getBusinessId();
if (businessId == null || businessId.isEmpty) {
return <Invoice>[];
}
final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData, data_connect.ListInvoicesByBusinessIdVariables> result = await executeProtected(() => _dataConnect final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData,
.listInvoicesByBusinessId( data_connect.ListInvoicesByBusinessIdVariables> result =
businessId: businessId, await _service.connector
) .listInvoicesByBusinessId(
.limit(10) businessId: businessId,
.execute()); )
.limit(10)
.execute();
return result.data.invoices.map(_mapInvoice).toList(); return result.data.invoices.map(_mapInvoice).toList();
});
} }
/// Fetches pending invoices (Open or Disputed). /// Fetches pending invoices (Open or Disputed).
@override @override
@override
Future<List<Invoice>> getPendingInvoices() async { Future<List<Invoice>> getPendingInvoices() async {
final String? businessId = return _service.run(() async {
data_connect.ClientSessionStore.instance.session?.business?.id; final String businessId = await _service.getBusinessId();
if (businessId == null || businessId.isEmpty) {
return <Invoice>[];
}
final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData, data_connect.ListInvoicesByBusinessIdVariables> result = await executeProtected(() => _dataConnect final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData,
.listInvoicesByBusinessId(businessId: businessId) data_connect.ListInvoicesByBusinessIdVariables> result =
.execute()); await _service.connector
.listInvoicesByBusinessId(businessId: businessId)
.execute();
return result.data.invoices return result.data.invoices
.map(_mapInvoice) .map(_mapInvoice)
.where( .where(
(Invoice i) => (Invoice i) =>
i.status == InvoiceStatus.open || i.status == InvoiceStatus.open ||
i.status == InvoiceStatus.disputed, i.status == InvoiceStatus.disputed,
) )
.toList(); .toList();
});
} }
/// Fetches the estimated savings amount. /// Fetches the estimated savings amount.
@@ -101,86 +108,81 @@ class BillingRepositoryImpl
/// Fetches the breakdown of spending. /// Fetches the breakdown of spending.
@override @override
Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period) async { Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period) async {
final String? businessId = return _service.run(() async {
data_connect.ClientSessionStore.instance.session?.business?.id; final String businessId = await _service.getBusinessId();
if (businessId == null || businessId.isEmpty) {
return <InvoiceItem>[];
}
final DateTime now = DateTime.now(); final DateTime now = DateTime.now();
final DateTime start; final DateTime start;
final DateTime end; final DateTime end;
if (period == BillingPeriod.week) { if (period == BillingPeriod.week) {
final int daysFromMonday = now.weekday - DateTime.monday; final int daysFromMonday = now.weekday - DateTime.monday;
final DateTime monday = DateTime( final DateTime monday = DateTime(
now.year, now.year,
now.month, now.month,
now.day, now.day,
).subtract(Duration(days: daysFromMonday)); ).subtract(Duration(days: daysFromMonday));
start = DateTime(monday.year, monday.month, monday.day); start = DateTime(monday.year, monday.month, monday.day);
end = DateTime(monday.year, monday.month, monday.day + 6, 23, 59, 59, 999); end = DateTime(
} else { monday.year, monday.month, monday.day + 6, 23, 59, 59, 999);
start = DateTime(now.year, now.month, 1);
end = DateTime(now.year, now.month + 1, 0, 23, 59, 59, 999);
}
final fdc.QueryResult<data_connect.ListShiftRolesByBusinessAndDatesSummaryData, data_connect.ListShiftRolesByBusinessAndDatesSummaryVariables> result = await executeProtected(() => _dataConnect
.listShiftRolesByBusinessAndDatesSummary(
businessId: businessId,
start: _toTimestamp(start),
end: _toTimestamp(end),
)
.execute());
final List<data_connect.ListShiftRolesByBusinessAndDatesSummaryShiftRoles>
shiftRoles = result.data.shiftRoles;
if (shiftRoles.isEmpty) {
return <InvoiceItem>[];
}
final Map<String, _RoleSummary> summary = <String, _RoleSummary>{};
for (final data_connect.ListShiftRolesByBusinessAndDatesSummaryShiftRoles role
in shiftRoles) {
final String roleId = role.roleId;
final String roleName = role.role.name;
final double hours = role.hours ?? 0.0;
final double totalValue = role.totalValue ?? 0.0;
final _RoleSummary? existing = summary[roleId];
if (existing == null) {
summary[roleId] = _RoleSummary(
roleId: roleId,
roleName: roleName,
totalHours: hours,
totalValue: totalValue,
);
} else { } else {
summary[roleId] = existing.copyWith( start = DateTime(now.year, now.month, 1);
totalHours: existing.totalHours + hours, end = DateTime(now.year, now.month + 1, 0, 23, 59, 59, 999);
totalValue: existing.totalValue + totalValue,
);
} }
}
return summary.values final fdc.QueryResult<
.map( data_connect.ListShiftRolesByBusinessAndDatesSummaryData,
(_RoleSummary item) => InvoiceItem( data_connect.ListShiftRolesByBusinessAndDatesSummaryVariables>
id: item.roleId, result = await _service.connector
invoiceId: item.roleId, .listShiftRolesByBusinessAndDatesSummary(
staffId: item.roleName, businessId: businessId,
workHours: item.totalHours, start: _service.toTimestamp(start),
rate: item.totalHours > 0 ? item.totalValue / item.totalHours : 0, end: _service.toTimestamp(end),
amount: item.totalValue, )
), .execute();
)
.toList();
}
fdc.Timestamp _toTimestamp(DateTime dateTime) { final List<data_connect.ListShiftRolesByBusinessAndDatesSummaryShiftRoles>
final DateTime utc = dateTime.toUtc(); shiftRoles = result.data.shiftRoles;
final int seconds = utc.millisecondsSinceEpoch ~/ 1000; if (shiftRoles.isEmpty) {
final int nanoseconds = return <InvoiceItem>[];
(utc.millisecondsSinceEpoch % 1000) * 1000000; }
return fdc.Timestamp(nanoseconds, seconds);
final Map<String, _RoleSummary> summary = <String, _RoleSummary>{};
for (final data_connect
.ListShiftRolesByBusinessAndDatesSummaryShiftRoles role
in shiftRoles) {
final String roleId = role.roleId;
final String roleName = role.role.name;
final double hours = role.hours ?? 0.0;
final double totalValue = role.totalValue ?? 0.0;
final _RoleSummary? existing = summary[roleId];
if (existing == null) {
summary[roleId] = _RoleSummary(
roleId: roleId,
roleName: roleName,
totalHours: hours,
totalValue: totalValue,
);
} else {
summary[roleId] = existing.copyWith(
totalHours: existing.totalHours + hours,
totalValue: existing.totalValue + totalValue,
);
}
}
return summary.values
.map(
(_RoleSummary item) => InvoiceItem(
id: item.roleId,
invoiceId: item.roleId,
staffId: item.roleName,
workHours: item.totalHours,
rate: item.totalHours > 0 ? item.totalValue / item.totalHours : 0,
amount: item.totalValue,
),
)
.toList();
});
} }
Invoice _mapInvoice(data_connect.ListInvoicesByBusinessIdInvoices invoice) { Invoice _mapInvoice(data_connect.ListInvoicesByBusinessIdInvoices invoice) {
@@ -193,7 +195,19 @@ class BillingRepositoryImpl
workAmount: invoice.amount, workAmount: invoice.amount,
addonsAmount: invoice.otherCharges ?? 0, addonsAmount: invoice.otherCharges ?? 0,
invoiceNumber: invoice.invoiceNumber, invoiceNumber: invoice.invoiceNumber,
issueDate: invoice.issueDate.toDateTime(), issueDate: _service.toDateTime(invoice.issueDate)!,
);
}
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),
); );
} }

View File

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

View File

@@ -0,0 +1,14 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../repositories/billing_repository.dart';
/// Use case for fetching the bank accounts associated with the business.
class GetBankAccountsUseCase extends NoInputUseCase<List<BusinessBankAccount>> {
/// Creates a [GetBankAccountsUseCase].
GetBankAccountsUseCase(this._repository);
final BillingRepository _repository;
@override
Future<List<BusinessBankAccount>> call() => _repository.getBankAccounts();
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import '../../domain/usecases/get_bank_accounts.dart';
import '../../domain/usecases/get_current_bill_amount.dart'; import '../../domain/usecases/get_current_bill_amount.dart';
import '../../domain/usecases/get_invoice_history.dart'; import '../../domain/usecases/get_invoice_history.dart';
import '../../domain/usecases/get_pending_invoices.dart'; import '../../domain/usecases/get_pending_invoices.dart';
@@ -16,12 +17,14 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
with BlocErrorHandler<BillingState> { with BlocErrorHandler<BillingState> {
/// Creates a [BillingBloc] with the given use cases. /// Creates a [BillingBloc] with the given use cases.
BillingBloc({ BillingBloc({
required GetBankAccountsUseCase getBankAccounts,
required GetCurrentBillAmountUseCase getCurrentBillAmount, required GetCurrentBillAmountUseCase getCurrentBillAmount,
required GetSavingsAmountUseCase getSavingsAmount, required GetSavingsAmountUseCase getSavingsAmount,
required GetPendingInvoicesUseCase getPendingInvoices, required GetPendingInvoicesUseCase getPendingInvoices,
required GetInvoiceHistoryUseCase getInvoiceHistory, required GetInvoiceHistoryUseCase getInvoiceHistory,
required GetSpendingBreakdownUseCase getSpendingBreakdown, required GetSpendingBreakdownUseCase getSpendingBreakdown,
}) : _getCurrentBillAmount = getCurrentBillAmount, }) : _getBankAccounts = getBankAccounts,
_getCurrentBillAmount = getCurrentBillAmount,
_getSavingsAmount = getSavingsAmount, _getSavingsAmount = getSavingsAmount,
_getPendingInvoices = getPendingInvoices, _getPendingInvoices = getPendingInvoices,
_getInvoiceHistory = getInvoiceHistory, _getInvoiceHistory = getInvoiceHistory,
@@ -31,6 +34,7 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
on<BillingPeriodChanged>(_onPeriodChanged); on<BillingPeriodChanged>(_onPeriodChanged);
} }
final GetBankAccountsUseCase _getBankAccounts;
final GetCurrentBillAmountUseCase _getCurrentBillAmount; final GetCurrentBillAmountUseCase _getCurrentBillAmount;
final GetSavingsAmountUseCase _getSavingsAmount; final GetSavingsAmountUseCase _getSavingsAmount;
final GetPendingInvoicesUseCase _getPendingInvoices; final GetPendingInvoicesUseCase _getPendingInvoices;
@@ -52,13 +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 currentBill = results[0] as double;
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 =
@@ -80,6 +86,7 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
pendingInvoices: uiPendingInvoices, pendingInvoices: uiPendingInvoices,
invoiceHistory: uiInvoiceHistory, invoiceHistory: uiInvoiceHistory,
spendingBreakdown: uiSpendingBreakdown, spendingBreakdown: uiSpendingBreakdown,
bankAccounts: bankAccounts,
), ),
); );
}, },

View File

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

View File

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

View File

@@ -1,166 +1,133 @@
import 'package:core_localization/core_localization.dart'; import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_state.dart';
/// Card showing the current payment method. /// Card showing the current payment method.
class PaymentMethodCard extends StatefulWidget { class PaymentMethodCard extends StatelessWidget {
/// Creates a [PaymentMethodCard]. /// Creates a [PaymentMethodCard].
const PaymentMethodCard({super.key}); const PaymentMethodCard({super.key});
@override
State<PaymentMethodCard> createState() => _PaymentMethodCardState();
}
class _PaymentMethodCardState extends State<PaymentMethodCard> {
late final Future<dc.GetAccountsByOwnerIdData?> _accountsFuture =
_loadAccounts();
Future<dc.GetAccountsByOwnerIdData?> _loadAccounts() async {
final String? businessId =
dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return null;
}
final fdc.QueryResult<
dc.GetAccountsByOwnerIdData,
dc.GetAccountsByOwnerIdVariables
>
result = await dc.ExampleConnector.instance
.getAccountsByOwnerId(ownerId: businessId)
.execute();
return result.data;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return FutureBuilder<dc.GetAccountsByOwnerIdData?>( return BlocBuilder<BillingBloc, BillingState>(
future: _accountsFuture, builder: (BuildContext context, BillingState state) {
builder: final List<BusinessBankAccount> accounts = state.bankAccounts;
( final BusinessBankAccount? account =
BuildContext context, accounts.isNotEmpty ? accounts.first : null;
AsyncSnapshot<dc.GetAccountsByOwnerIdData?> snapshot,
) {
final List<dc.GetAccountsByOwnerIdAccounts> accounts =
snapshot.data?.accounts ?? <dc.GetAccountsByOwnerIdAccounts>[];
final dc.GetAccountsByOwnerIdAccounts? account = accounts.isNotEmpty
? accounts.first
: null;
if (account == null) { if (account == null) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final String bankLabel = account.bank.isNotEmpty == true final String bankLabel =
? account.bank account.bankName.isNotEmpty == true ? account.bankName : '----';
: '----'; final String last4 =
final String last4 = account.last4.isNotEmpty == true account.last4.isNotEmpty == true ? account.last4 : '----';
? account.last4 final bool isPrimary = account.isPrimary;
: '----'; final String expiryLabel = _formatExpiry(account.expiryTime);
final bool isPrimary = account.isPrimary ?? false;
final String expiryLabel = _formatExpiry(account.expiryTime);
return Container( return Container(
padding: const EdgeInsets.all(UiConstants.space4), padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: UiColors.white, color: UiColors.white,
borderRadius: UiConstants.radiusLg, borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border), border: Border.all(color: UiColors.border),
boxShadow: <BoxShadow>[ boxShadow: <BoxShadow>[
BoxShadow( BoxShadow(
color: UiColors.black.withValues(alpha: 0.04), color: UiColors.black.withValues(alpha: 0.04),
blurRadius: 8, blurRadius: 8,
offset: const Offset(0, 2), offset: const Offset(0, 2),
),
],
), ),
child: Column( ],
),
child: Column(
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[ children: <Widget>[
Row( Text(
mainAxisAlignment: MainAxisAlignment.spaceBetween, t.client_billing.payment_method,
children: <Widget>[ style: UiTypography.title2b.textPrimary,
Text(
t.client_billing.payment_method,
style: UiTypography.title2b.textPrimary,
),
const SizedBox.shrink(),
],
),
const SizedBox(height: UiConstants.space3),
Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: UiConstants.radiusMd,
),
child: Row(
children: <Widget>[
Container(
width: UiConstants.space10,
height: UiConstants.space6 + 4,
decoration: BoxDecoration(
color: UiColors.primary,
borderRadius: UiConstants.radiusSm,
),
child: Center(
child: Text(
bankLabel,
style: UiTypography.footnote2b.white,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'•••• $last4',
style: UiTypography.body2b.textPrimary,
),
Text(
t.client_billing.expires(date: expiryLabel),
style: UiTypography.footnote2r.textSecondary,
),
],
),
),
if (isPrimary)
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1,
),
decoration: BoxDecoration(
color: UiColors.accent,
borderRadius: UiConstants.radiusSm,
),
child: Text(
t.client_billing.default_badge,
style: UiTypography.titleUppercase4b.textPrimary,
),
),
],
),
), ),
const SizedBox.shrink(),
], ],
), ),
); const SizedBox(height: UiConstants.space3),
}, Container(
padding: const EdgeInsets.all(UiConstants.space3),
decoration: BoxDecoration(
color: UiColors.bgSecondary,
borderRadius: UiConstants.radiusMd,
),
child: Row(
children: <Widget>[
Container(
width: UiConstants.space10,
height: UiConstants.space6 + 4,
decoration: BoxDecoration(
color: UiColors.primary,
borderRadius: UiConstants.radiusSm,
),
child: Center(
child: Text(
bankLabel,
style: UiTypography.footnote2b.white,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'•••• $last4',
style: UiTypography.body2b.textPrimary,
),
Text(
t.client_billing.expires(date: expiryLabel),
style: UiTypography.footnote2r.textSecondary,
),
],
),
),
if (isPrimary)
Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1,
),
decoration: BoxDecoration(
color: UiColors.accent,
borderRadius: UiConstants.radiusSm,
),
child: Text(
t.client_billing.default_badge,
style: UiTypography.titleUppercase4b.textPrimary,
),
),
],
),
),
],
),
);
},
); );
} }
String _formatExpiry(fdc.Timestamp? expiryTime) { String _formatExpiry(DateTime? expiryTime) {
if (expiryTime == null) { if (expiryTime == null) {
return 'N/A'; return 'N/A';
} }
final DateTime date = expiryTime.toDateTime(); final String month = expiryTime.month.toString().padLeft(2, '0');
final String month = date.month.toString().padLeft(2, '0'); final String year = (expiryTime.year % 100).toString().padLeft(2, '0');
final String year = (date.year % 100).toString().padLeft(2, '0');
return '$month/$year'; return '$month/$year';
} }
} }

View File

@@ -10,24 +10,20 @@ import 'presentation/pages/coverage_page.dart';
/// Modular module for the coverage feature. /// Modular module for the coverage feature.
class CoverageModule extends Module { class CoverageModule extends Module {
@override
List<Module> get imports => <Module>[DataConnectModule()];
@override @override
void binds(Injector i) { void binds(Injector i) {
// Repositories // Repositories
i.addSingleton<CoverageRepository>( i.addSingleton<CoverageRepository>(CoverageRepositoryImpl.new);
() => CoverageRepositoryImpl(dataConnect: ExampleConnector.instance),
);
// Use Cases // Use Cases
i.addSingleton(GetShiftsForDateUseCase.new); i.addSingleton(GetShiftsForDateUseCase.new);
i.addSingleton(GetCoverageStatsUseCase.new); i.addSingleton(GetCoverageStatsUseCase.new);
// BLoCs // BLoCs
i.addSingleton<CoverageBloc>( i.addSingleton<CoverageBloc>(CoverageBloc.new);
() => CoverageBloc(
getShiftsForDate: i.get<GetShiftsForDateUseCase>(),
getCoverageStats: i.get<GetCoverageStatsUseCase>(),
),
);
} }
@override @override

View File

@@ -15,44 +15,36 @@ import '../../domain/repositories/coverage_repository.dart';
/// - Returns domain entities from `domain/ui_entities`. /// - Returns domain entities from `domain/ui_entities`.
class CoverageRepositoryImpl implements CoverageRepository { class CoverageRepositoryImpl implements CoverageRepository {
/// Creates a [CoverageRepositoryImpl]. /// Creates a [CoverageRepositoryImpl].
CoverageRepositoryImpl({required dc.ExampleConnector dataConnect}) CoverageRepositoryImpl({required dc.DataConnectService service}) : _service = service;
: _dataConnect = dataConnect;
final dc.ExampleConnector _dataConnect; final dc.DataConnectService _service;
/// Fetches shifts for a specific date. /// Fetches shifts for a specific date.
@override @override
Future<List<CoverageShift>> getShiftsForDate({required DateTime date}) async { Future<List<CoverageShift>> getShiftsForDate({required DateTime date}) async {
try { return _service.run(() async {
final String? businessId = final String businessId = await _service.getBusinessId();
dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return <CoverageShift>[];
}
final DateTime start = DateTime(date.year, date.month, date.day); final DateTime start = DateTime(date.year, date.month, date.day);
final DateTime end = final DateTime end = DateTime(date.year, date.month, date.day, 23, 59, 59, 999);
DateTime(date.year, date.month, date.day, 23, 59, 59, 999);
final fdc.QueryResult< final fdc.QueryResult<dc.ListShiftRolesByBusinessAndDateRangeData,
dc.ListShiftRolesByBusinessAndDateRangeData, dc.ListShiftRolesByBusinessAndDateRangeVariables> shiftRolesResult =
dc.ListShiftRolesByBusinessAndDateRangeVariables> shiftRolesResult = await _service.connector
await _dataConnect
.listShiftRolesByBusinessAndDateRange( .listShiftRolesByBusinessAndDateRange(
businessId: businessId, businessId: businessId,
start: _toTimestamp(start), start: _service.toTimestamp(start),
end: _toTimestamp(end), end: _service.toTimestamp(end),
) )
.execute(); .execute();
final fdc.QueryResult< final fdc.QueryResult<dc.ListStaffsApplicationsByBusinessForDayData,
dc.ListStaffsApplicationsByBusinessForDayData, dc.ListStaffsApplicationsByBusinessForDayVariables> applicationsResult =
dc.ListStaffsApplicationsByBusinessForDayVariables> applicationsResult = await _service.connector
await _dataConnect
.listStaffsApplicationsByBusinessForDay( .listStaffsApplicationsByBusinessForDay(
businessId: businessId, businessId: businessId,
dayStart: _toTimestamp(start), dayStart: _service.toTimestamp(start),
dayEnd: _toTimestamp(end), dayEnd: _service.toTimestamp(end),
) )
.execute(); .execute();
@@ -61,18 +53,7 @@ class CoverageRepositoryImpl implements CoverageRepository {
applicationsResult.data.applications, applicationsResult.data.applications,
date, date,
); );
} catch (e) { });
final String error = e.toString().toLowerCase();
if (error.contains('network') ||
error.contains('connection') ||
error.contains('unavailable') ||
error.contains('offline') ||
error.contains('socket') ||
error.contains('failed host lookup')) {
throw NetworkException(technicalMessage: 'Coverage fetch failed: $e');
}
throw ServerException(technicalMessage: 'Coverage fetch failed: $e');
}
} }
/// Fetches coverage statistics for a specific date. /// Fetches coverage statistics for a specific date.
@@ -110,14 +91,6 @@ class CoverageRepositoryImpl implements CoverageRepository {
); );
} }
fdc.Timestamp _toTimestamp(DateTime dateTime) {
final DateTime utc = dateTime.toUtc();
final int seconds = utc.millisecondsSinceEpoch ~/ 1000;
final int nanoseconds =
(utc.millisecondsSinceEpoch % 1000) * 1000000;
return fdc.Timestamp(nanoseconds, seconds);
}
List<CoverageShift> _mapCoverageShifts( List<CoverageShift> _mapCoverageShifts(
List<dc.ListShiftRolesByBusinessAndDateRangeShiftRoles> shiftRoles, List<dc.ListShiftRolesByBusinessAndDateRangeShiftRoles> shiftRoles,
List<dc.ListStaffsApplicationsByBusinessForDayApplications> applications, List<dc.ListStaffsApplicationsByBusinessForDayApplications> applications,
@@ -151,10 +124,12 @@ class CoverageRepositoryImpl implements CoverageRepository {
shiftId: app.shiftId, shiftId: app.shiftId,
roleId: app.roleId, roleId: app.roleId,
title: app.shiftRole.role.name, title: app.shiftRole.role.name,
location: app.shiftRole.shift.location ?? '', location: app.shiftRole.shift.location ??
startTime: '00:00', app.shiftRole.shift.locationAddress ??
workersNeeded: 0, '',
date: date, startTime: _formatTime(app.shiftRole.startTime) ?? '00:00',
workersNeeded: app.shiftRole.count,
date: app.shiftRole.shift.date?.toDateTime() ?? date,
workers: <CoverageWorker>[], workers: <CoverageWorker>[],
); );

View File

@@ -2,7 +2,6 @@ import 'package:flutter/widgets.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 'package:krow_data_connect/krow_data_connect.dart';
import 'package:firebase_auth/firebase_auth.dart' as firebase;
import 'data/repositories_impl/client_create_order_repository_impl.dart'; import 'data/repositories_impl/client_create_order_repository_impl.dart';
import 'domain/repositories/client_create_order_repository_interface.dart'; import 'domain/repositories/client_create_order_repository_interface.dart';
import 'domain/usecases/create_one_time_order_usecase.dart'; import 'domain/usecases/create_one_time_order_usecase.dart';
@@ -29,12 +28,7 @@ class ClientCreateOrderModule extends Module {
@override @override
void binds(Injector i) { void binds(Injector i) {
// Repositories // Repositories
i.addLazySingleton<ClientCreateOrderRepositoryInterface>( i.addLazySingleton<ClientCreateOrderRepositoryInterface>(ClientCreateOrderRepositoryImpl.new);
() => ClientCreateOrderRepositoryImpl(
firebaseAuth: firebase.FirebaseAuth.instance,
dataConnect: ExampleConnector.instance,
),
);
// UseCases // UseCases
i.addLazySingleton(GetOrderTypesUseCase.new); i.addLazySingleton(GetOrderTypesUseCase.new);
@@ -44,12 +38,7 @@ class ClientCreateOrderModule extends Module {
// BLoCs // BLoCs
i.add<ClientCreateOrderBloc>(ClientCreateOrderBloc.new); i.add<ClientCreateOrderBloc>(ClientCreateOrderBloc.new);
i.add<RapidOrderBloc>(RapidOrderBloc.new); i.add<RapidOrderBloc>(RapidOrderBloc.new);
i.add<OneTimeOrderBloc>( i.add<OneTimeOrderBloc>(OneTimeOrderBloc.new);
() => OneTimeOrderBloc(
i.get<CreateOneTimeOrderUseCase>(),
ExampleConnector.instance,
),
);
} }
@override @override

View File

@@ -1,4 +1,3 @@
import 'package:firebase_auth/firebase_auth.dart' as firebase;
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_data_connect/krow_data_connect.dart' as dc;
@@ -7,27 +6,21 @@ import '../../domain/repositories/client_create_order_repository_interface.dart'
/// Implementation of [ClientCreateOrderRepositoryInterface]. /// Implementation of [ClientCreateOrderRepositoryInterface].
/// ///
/// This implementation coordinates data access for order creation by delegating /// This implementation coordinates data access for order creation by [DataConnectService] from the shared
/// to the [OrderRepositoryMock] and [ExampleConnector] from the shared
/// Data Connect package. /// Data Connect package.
/// ///
/// It follows the KROW Clean Architecture by keeping the data layer focused /// It follows the KROW Clean Architecture by keeping the data layer focused
/// on delegation and data mapping, without business logic. /// on delegation and data mapping, without business logic.
class ClientCreateOrderRepositoryImpl class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInterface {
with dc.DataErrorHandler
implements ClientCreateOrderRepositoryInterface {
ClientCreateOrderRepositoryImpl({ ClientCreateOrderRepositoryImpl({
required firebase.FirebaseAuth firebaseAuth, required dc.DataConnectService service,
required dc.ExampleConnector dataConnect, }) : _service = service;
}) : _firebaseAuth = firebaseAuth,
_dataConnect = dataConnect;
final firebase.FirebaseAuth _firebaseAuth; final dc.DataConnectService _service;
final dc.ExampleConnector _dataConnect;
@override @override
Future<List<domain.OrderType>> getOrderTypes() { Future<List<domain.OrderType>> getOrderTypes() {
return Future.value(const <domain.OrderType>[ return Future<List<domain.OrderType>>.value(const <domain.OrderType>[
domain.OrderType( domain.OrderType(
id: 'one-time', id: 'one-time',
titleKey: 'client_create_order.types.one_time', titleKey: 'client_create_order.types.one_time',
@@ -55,100 +48,95 @@ class ClientCreateOrderRepositoryImpl
@override @override
Future<void> createOneTimeOrder(domain.OneTimeOrder order) async { Future<void> createOneTimeOrder(domain.OneTimeOrder order) async {
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id; return _service.run(() async {
if (businessId == null || businessId.isEmpty) { final String businessId = await _service.getBusinessId();
await _firebaseAuth.signOut(); final String? vendorId = order.vendorId;
throw Exception('Business is missing. Please sign in again.'); if (vendorId == null || vendorId.isEmpty) {
} throw Exception('Vendor is missing.');
final String? vendorId = order.vendorId; }
if (vendorId == null || vendorId.isEmpty) { final domain.OneTimeOrderHubDetails? hub = order.hub;
throw Exception('Vendor is missing.'); if (hub == null || hub.id.isEmpty) {
} throw Exception('Hub is missing.');
final domain.OneTimeOrderHubDetails? hub = order.hub; }
if (hub == null || hub.id.isEmpty) {
throw Exception('Hub is missing.');
}
final DateTime orderDateOnly = DateTime( final DateTime orderDateOnly = DateTime(
order.date.year, order.date.year,
order.date.month, order.date.month,
order.date.day, order.date.day,
); );
final fdc.Timestamp orderTimestamp = _toTimestamp(orderDateOnly); final fdc.Timestamp orderTimestamp = _service.toTimestamp(orderDateOnly);
final fdc.OperationResult<dc.CreateOrderData, dc.CreateOrderVariables> final fdc.OperationResult<dc.CreateOrderData, dc.CreateOrderVariables> orderResult =
orderResult = await executeProtected(() => _dataConnect await _service.connector
.createOrder( .createOrder(
businessId: businessId, businessId: businessId,
orderType: dc.OrderType.ONE_TIME, orderType: dc.OrderType.ONE_TIME,
teamHubId: hub.id, teamHubId: hub.id,
)
.vendorId(vendorId)
.eventName(order.eventName)
.status(dc.OrderStatus.POSTED)
.date(orderTimestamp)
.execute();
final String orderId = orderResult.data.order_insert.id;
final int workersNeeded = order.positions.fold<int>(
0,
(int sum, domain.OneTimeOrderPosition position) => sum + position.count,
);
final String shiftTitle = 'Shift 1 ${_formatDate(order.date)}';
final double shiftCost = _calculateShiftCost(order);
final fdc.OperationResult<dc.CreateShiftData, dc.CreateShiftVariables> shiftResult =
await _service.connector
.createShift(title: shiftTitle, orderId: orderId)
.date(orderTimestamp)
.location(hub.name)
.locationAddress(hub.address)
.latitude(hub.latitude)
.longitude(hub.longitude)
.placeId(hub.placeId)
.city(hub.city)
.state(hub.state)
.street(hub.street)
.country(hub.country)
.status(dc.ShiftStatus.PENDING)
.workersNeeded(workersNeeded)
.filled(0)
.durationDays(1)
.cost(shiftCost)
.execute();
final String shiftId = shiftResult.data.shift_insert.id;
for (final domain.OneTimeOrderPosition position in order.positions) {
final DateTime start = _parseTime(order.date, position.startTime);
final DateTime end = _parseTime(order.date, position.endTime);
final DateTime normalizedEnd = end.isBefore(start) ? end.add(const Duration(days: 1)) : end;
final double hours = normalizedEnd.difference(start).inMinutes / 60.0;
final double rate = order.roleRates[position.role] ?? 0;
final double totalValue = rate * hours * position.count;
await _service.connector
.createShiftRole(
shiftId: shiftId,
roleId: position.role,
count: position.count,
) )
.vendorId(vendorId) .startTime(_service.toTimestamp(start))
.eventName(order.eventName) .endTime(_service.toTimestamp(normalizedEnd))
.status(dc.OrderStatus.POSTED) .hours(hours)
.date(orderTimestamp) .breakType(_breakDurationFromValue(position.lunchBreak))
.execute()); .isBreakPaid(_isBreakPaid(position.lunchBreak))
.totalValue(totalValue)
.execute();
}
final String orderId = orderResult.data.order_insert.id; await _service.connector
.updateOrder(id: orderId, teamHubId: hub.id)
final int workersNeeded = order.positions.fold<int>( .shifts(fdc.AnyValue(<String>[shiftId]))
0, .execute();
(int sum, domain.OneTimeOrderPosition position) => sum + position.count, });
);
final String shiftTitle = 'Shift 1 ${_formatDate(order.date)}';
final double shiftCost = _calculateShiftCost(order);
final fdc.OperationResult<dc.CreateShiftData, dc.CreateShiftVariables>
shiftResult = await executeProtected(() => _dataConnect
.createShift(title: shiftTitle, orderId: orderId)
.date(orderTimestamp)
.location(hub.name)
.locationAddress(hub.address)
.latitude(hub.latitude)
.longitude(hub.longitude)
.placeId(hub.placeId)
.city(hub.city)
.state(hub.state)
.street(hub.street)
.country(hub.country)
.status(dc.ShiftStatus.PENDING)
.workersNeeded(workersNeeded)
.filled(0)
.durationDays(1)
.cost(shiftCost)
.execute());
final String shiftId = shiftResult.data.shift_insert.id;
for (final domain.OneTimeOrderPosition position in order.positions) {
final DateTime start = _parseTime(order.date, position.startTime);
final DateTime end = _parseTime(order.date, position.endTime);
final DateTime normalizedEnd =
end.isBefore(start) ? end.add(const Duration(days: 1)) : end;
final double hours = normalizedEnd.difference(start).inMinutes / 60.0;
final double rate = order.roleRates[position.role] ?? 0;
final double totalValue = rate * hours * position.count;
await executeProtected(() => _dataConnect
.createShiftRole(
shiftId: shiftId,
roleId: position.role,
count: position.count,
)
.startTime(_toTimestamp(start))
.endTime(_toTimestamp(normalizedEnd))
.hours(hours)
.breakType(_breakDurationFromValue(position.lunchBreak))
.isBreakPaid(_isBreakPaid(position.lunchBreak))
.totalValue(totalValue)
.execute());
}
await executeProtected(() => _dataConnect
.updateOrder(id: orderId, teamHubId: hub.id)
.shifts(fdc.AnyValue(<String>[shiftId]))
.execute());
} }
@override @override
@@ -213,13 +201,6 @@ class ClientCreateOrderRepositoryImpl
); );
} }
fdc.Timestamp _toTimestamp(DateTime dateTime) {
final DateTime utc = dateTime.toUtc();
final int seconds = utc.millisecondsSinceEpoch ~/ 1000;
final int nanoseconds = (utc.microsecondsSinceEpoch % 1000000) * 1000;
return fdc.Timestamp(nanoseconds, seconds);
}
String _formatDate(DateTime dateTime) { String _formatDate(DateTime dateTime) {
final String year = dateTime.year.toString().padLeft(4, '0'); final String year = dateTime.year.toString().padLeft(4, '0');
final String month = dateTime.month.toString().padLeft(2, '0'); final String month = dateTime.month.toString().padLeft(2, '0');

View File

@@ -11,7 +11,7 @@ import 'one_time_order_state.dart';
/// BLoC for managing the multi-step one-time order creation form. /// BLoC for managing the multi-step one-time order creation form.
class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState> class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
with BlocErrorHandler<OneTimeOrderState>, SafeBloc<OneTimeOrderEvent, OneTimeOrderState> { with BlocErrorHandler<OneTimeOrderState>, SafeBloc<OneTimeOrderEvent, OneTimeOrderState> {
OneTimeOrderBloc(this._createOneTimeOrderUseCase, this._dataConnect) OneTimeOrderBloc(this._createOneTimeOrderUseCase, this._service)
: super(OneTimeOrderState.initial()) { : super(OneTimeOrderState.initial()) {
on<OneTimeOrderVendorsLoaded>(_onVendorsLoaded); on<OneTimeOrderVendorsLoaded>(_onVendorsLoaded);
on<OneTimeOrderVendorChanged>(_onVendorChanged); on<OneTimeOrderVendorChanged>(_onVendorChanged);
@@ -28,13 +28,13 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
_loadHubs(); _loadHubs();
} }
final CreateOneTimeOrderUseCase _createOneTimeOrderUseCase; final CreateOneTimeOrderUseCase _createOneTimeOrderUseCase;
final dc.ExampleConnector _dataConnect; final dc.DataConnectService _service;
Future<void> _loadVendors() async { Future<void> _loadVendors() async {
final List<Vendor>? vendors = await handleErrorWithResult( final List<Vendor>? vendors = await handleErrorWithResult(
action: () async { action: () async {
final QueryResult<dc.ListVendorsData, void> result = final QueryResult<dc.ListVendorsData, void> result =
await _dataConnect.listVendors().execute(); await _service.connector.listVendors().execute();
return result.data.vendors return result.data.vendors
.map( .map(
(dc.ListVendorsVendors vendor) => Vendor( (dc.ListVendorsVendors vendor) => Vendor(
@@ -57,7 +57,7 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
final List<OneTimeOrderRoleOption>? roles = await handleErrorWithResult( final List<OneTimeOrderRoleOption>? roles = await handleErrorWithResult(
action: () async { action: () async {
final QueryResult<dc.ListRolesByVendorIdData, dc.ListRolesByVendorIdVariables> final QueryResult<dc.ListRolesByVendorIdData, dc.ListRolesByVendorIdVariables>
result = await _dataConnect.listRolesByVendorId(vendorId: vendorId).execute(); result = await _service.connector.listRolesByVendorId(vendorId: vendorId).execute();
return result.data.roles return result.data.roles
.map( .map(
(dc.ListRolesByVendorIdRoles role) => OneTimeOrderRoleOption( (dc.ListRolesByVendorIdRoles role) => OneTimeOrderRoleOption(
@@ -79,13 +79,9 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
Future<void> _loadHubs() async { Future<void> _loadHubs() async {
final List<OneTimeOrderHubOption>? hubs = await handleErrorWithResult( final List<OneTimeOrderHubOption>? hubs = await handleErrorWithResult(
action: () async { action: () async {
final String? businessId = final String businessId = await _service.getBusinessId();
dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return <OneTimeOrderHubOption>[];
}
final QueryResult<dc.ListTeamHubsByOwnerIdData, dc.ListTeamHubsByOwnerIdVariables> final QueryResult<dc.ListTeamHubsByOwnerIdData, dc.ListTeamHubsByOwnerIdVariables>
result = await _dataConnect result = await _service.connector
.listTeamHubsByOwnerId(ownerId: businessId) .listTeamHubsByOwnerId(ownerId: businessId)
.execute(); .execute();
return result.data.teamHubs return result.data.teamHubs

View File

@@ -63,6 +63,8 @@ class OneTimeOrderSuccessView extends StatelessWidget {
color: UiColors.accent, color: UiColors.accent,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: const Center( child: const Center(
child: Icon( child: Icon(
UiIcons.check, UiIcons.check,

View File

@@ -72,6 +72,12 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
TextPosition(offset: _messageController.text.length), TextPosition(offset: _messageController.text.length),
); );
} }
} else if (state is RapidOrderFailure) {
UiSnackbar.show(
context,
message: translateErrorKey(state.error),
type: UiSnackbarType.error,
);
} }
}, },
child: Scaffold( child: Scaffold(

View File

@@ -23,11 +23,7 @@ class ClientHomeModule extends Module {
@override @override
void binds(Injector i) { void binds(Injector i) {
// Repositories // Repositories
i.addLazySingleton<HomeRepositoryInterface>( i.addLazySingleton<HomeRepositoryInterface>(HomeRepositoryImpl.new);
() => HomeRepositoryImpl(
ExampleConnector.instance,
),
);
// UseCases // UseCases
i.addLazySingleton(GetDashboardDataUseCase.new); i.addLazySingleton(GetDashboardDataUseCase.new);
@@ -35,13 +31,7 @@ class ClientHomeModule extends Module {
i.addLazySingleton(GetUserSessionDataUseCase.new); i.addLazySingleton(GetUserSessionDataUseCase.new);
// BLoCs // BLoCs
i.add<ClientHomeBloc>( i.add<ClientHomeBloc>(ClientHomeBloc.new);
() => ClientHomeBloc(
getDashboardDataUseCase: i.get<GetDashboardDataUseCase>(),
getRecentReordersUseCase: i.get<GetRecentReordersUseCase>(),
getUserSessionDataUseCase: i.get<GetUserSessionDataUseCase>(),
),
);
} }
@override @override

View File

@@ -8,43 +8,47 @@ import '../../domain/repositories/home_repository_interface.dart';
/// This implementation resides in the data layer and acts as a bridge between the /// This implementation resides in the data layer and acts as a bridge between the
/// domain layer and the data source (in this case, a mock from data_connect). /// domain layer and the data source (in this case, a mock from data_connect).
class HomeRepositoryImpl implements HomeRepositoryInterface { class HomeRepositoryImpl implements HomeRepositoryInterface {
/// Creates a [HomeRepositoryImpl]. /// Creates a [HomeRepositoryImpl].
HomeRepositoryImpl(this._dataConnect); HomeRepositoryImpl(this._service);
final dc.ExampleConnector _dataConnect; final dc.DataConnectService _service;
@override @override
Future<HomeDashboardData> getDashboardData() async { Future<HomeDashboardData> getDashboardData() async {
try { return _service.run(() async {
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id; final String businessId = await _service.getBusinessId();
if (businessId == null || businessId.isEmpty) {
return const HomeDashboardData(
weeklySpending: 0,
next7DaysSpending: 0,
weeklyShifts: 0,
next7DaysScheduled: 0,
totalNeeded: 0,
totalFilled: 0,
);
}
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 DateTime weekRangeStart = DateTime(
monday.year,
monday.month,
monday.day,
);
final DateTime weekRangeEnd = DateTime(
monday.year,
monday.month,
monday.day + 13,
23,
59,
59,
999,
);
final fdc.QueryResult< final fdc.QueryResult<
dc.GetCompletedShiftsByBusinessIdData, dc.GetCompletedShiftsByBusinessIdData,
dc.GetCompletedShiftsByBusinessIdVariables> completedResult = dc.GetCompletedShiftsByBusinessIdVariables
await _dataConnect >
.getCompletedShiftsByBusinessId( completedResult = await _service.connector
businessId: businessId, .getCompletedShiftsByBusinessId(
dateFrom: _toTimestamp(weekRangeStart), businessId: businessId,
dateTo: _toTimestamp(weekRangeEnd), dateFrom: _service.toTimestamp(weekRangeStart),
) dateTo: _service.toTimestamp(weekRangeEnd),
.execute(); )
.execute();
double weeklySpending = 0.0; double weeklySpending = 0.0;
double next7DaysSpending = 0.0; double next7DaysSpending = 0.0;
@@ -71,18 +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< final fdc.QueryResult<
dc.ListShiftRolesByBusinessAndDateRangeData, dc.ListShiftRolesByBusinessAndDateRangeData,
dc.ListShiftRolesByBusinessAndDateRangeVariables> result = dc.ListShiftRolesByBusinessAndDateRangeVariables
await _dataConnect >
.listShiftRolesByBusinessAndDateRange( result = await _service.connector
businessId: businessId, .listShiftRolesByBusinessAndDateRange(
start: _toTimestamp(start), businessId: businessId,
end: _toTimestamp(end), start: _service.toTimestamp(start),
) end: _service.toTimestamp(end),
.execute(); )
.execute();
int totalNeeded = 0; int totalNeeded = 0;
int totalFilled = 0; int totalFilled = 0;
@@ -100,59 +113,80 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
totalNeeded: totalNeeded, totalNeeded: totalNeeded,
totalFilled: totalFilled, totalFilled: totalFilled,
); );
} catch (e) { });
final String error = e.toString().toLowerCase();
if (error.contains('network') ||
error.contains('connection') ||
error.contains('unavailable') ||
error.contains('offline') ||
error.contains('socket') ||
error.contains('failed host lookup')) {
throw NetworkException(technicalMessage: 'Home dashboard fetch failed: $e');
}
throw ServerException(technicalMessage: 'Home dashboard fetch failed: $e');
}
} }
@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
Future<List<ReorderItem>> getRecentReorders() async { Future<List<ReorderItem>> getRecentReorders() async {
try { return _service.run(() async {
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id; final String businessId = await _service.getBusinessId();
if (businessId == null || businessId.isEmpty) {
return const <ReorderItem>[];
}
final DateTime now = DateTime.now(); final DateTime now = DateTime.now();
final DateTime start = now.subtract(const Duration(days: 30)); final DateTime start = now.subtract(const Duration(days: 30));
final fdc.Timestamp startTimestamp = _toTimestamp(start); final fdc.Timestamp startTimestamp = _service.toTimestamp(start);
final fdc.Timestamp endTimestamp = _toTimestamp(now); final fdc.Timestamp endTimestamp = _service.toTimestamp(now);
final fdc.QueryResult< final fdc.QueryResult<
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersData, dc.ListShiftRolesByBusinessDateRangeCompletedOrdersData,
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersVariables> result = dc.ListShiftRolesByBusinessDateRangeCompletedOrdersVariables
await _dataConnect.listShiftRolesByBusinessDateRangeCompletedOrders( >
result = await _service.connector
.listShiftRolesByBusinessDateRangeCompletedOrders(
businessId: businessId, businessId: businessId,
start: startTimestamp, start: startTimestamp,
end: endTimestamp, end: endTimestamp,
).execute(); )
.execute();
return result.data.shiftRoles.map(( return result.data.shiftRoles.map((
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersShiftRoles shiftRole, dc.ListShiftRolesByBusinessDateRangeCompletedOrdersShiftRoles shiftRole,
) { ) {
final String location = final String location =
shiftRole.shift.location ?? shiftRole.shift.location ?? shiftRole.shift.locationAddress ?? '';
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,
@@ -164,25 +198,6 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
type: type, type: type,
); );
}).toList(); }).toList();
} catch (e) { });
final String error = e.toString().toLowerCase();
if (error.contains('network') ||
error.contains('connection') ||
error.contains('unavailable') ||
error.contains('offline') ||
error.contains('socket') ||
error.contains('failed host lookup')) {
throw NetworkException(technicalMessage: 'Home reorders fetch failed: $e');
}
throw ServerException(technicalMessage: 'Home reorders fetch failed: $e');
}
}
fdc.Timestamp _toTimestamp(DateTime date) {
final DateTime utc = date.toUtc();
final int millis = utc.millisecondsSinceEpoch;
final int seconds = millis ~/ 1000;
final int nanos = (millis % 1000) * 1000000;
return fdc.Timestamp(nanos, seconds);
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,8 +22,8 @@ class CoverageDashboard extends StatelessWidget {
int totalConfirmed = 0; int totalConfirmed = 0;
double todayCost = 0; double todayCost = 0;
for (final s in shifts) { for (final dynamic s in shifts) {
final int needed = s['workersNeeded'] as int? ?? 0; final int needed = (s as Map<String, dynamic>)['workersNeeded'] as int? ?? 0;
final int confirmed = s['filled'] as int? ?? 0; final int confirmed = s['filled'] as int? ?? 0;
final double rate = s['hourlyRate'] as double? ?? 0.0; final double rate = s['hourlyRate'] as double? ?? 0.0;
final double hours = s['hours'] as double? ?? 0.0; final double hours = s['hours'] as double? ?? 0.0;
@@ -39,10 +39,10 @@ class CoverageDashboard extends StatelessWidget {
final int unfilledPositions = totalNeeded - totalConfirmed; final int unfilledPositions = totalNeeded - totalConfirmed;
final int checkedInCount = applications final int checkedInCount = applications
.where((a) => (a as Map)['checkInTime'] != null) .where((dynamic a) => (a as Map<String, dynamic>)['checkInTime'] != null)
.length; .length;
final int lateWorkersCount = applications final int lateWorkersCount = applications
.where((a) => (a as Map)['status'] == 'LATE') .where((dynamic a) => (a as Map<String, dynamic>)['status'] == 'LATE')
.length; .length;
final bool isCoverageGood = coveragePercent >= 90; final bool isCoverageGood = coveragePercent >= 90;

View File

@@ -1,3 +1,4 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart'; import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -47,7 +48,7 @@ class CoverageWidget extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[ children: <Widget>[
Text( Text(
"TODAY'S COVERAGE", t.client_home.dashboard.todays_coverage,
style: UiTypography.footnote1b.copyWith( style: UiTypography.footnote1b.copyWith(
color: UiColors.textPrimary, color: UiColors.textPrimary,
letterSpacing: 0.5, letterSpacing: 0.5,
@@ -64,8 +65,8 @@ class CoverageWidget extends StatelessWidget {
color: backgroundColor, color: backgroundColor,
borderRadius: UiConstants.radiusLg, borderRadius: UiConstants.radiusLg,
), ),
child: Text( child: Text(
'$coveragePercent% Covered', t.client_home.dashboard.percent_covered(percent: coveragePercent),
style: UiTypography.footnote2b.copyWith(color: textColor), style: UiTypography.footnote2b.copyWith(color: textColor),
), ),
), ),
@@ -81,7 +82,7 @@ class CoverageWidget extends StatelessWidget {
child: _MetricCard( child: _MetricCard(
icon: UiIcons.target, icon: UiIcons.target,
iconColor: UiColors.primary, iconColor: UiColors.primary,
label: 'Needed', label: t.client_home.dashboard.metric_needed,
value: '$totalNeeded', value: '$totalNeeded',
), ),
), ),
@@ -91,7 +92,7 @@ class CoverageWidget extends StatelessWidget {
child: _MetricCard( child: _MetricCard(
icon: UiIcons.success, icon: UiIcons.success,
iconColor: UiColors.iconSuccess, iconColor: UiColors.iconSuccess,
label: 'Filled', label: t.client_home.dashboard.metric_filled,
value: '$totalConfirmed', value: '$totalConfirmed',
valueColor: UiColors.textSuccess, valueColor: UiColors.textSuccess,
), ),
@@ -101,7 +102,7 @@ class CoverageWidget extends StatelessWidget {
child: _MetricCard( child: _MetricCard(
icon: UiIcons.error, icon: UiIcons.error,
iconColor: UiColors.iconError, iconColor: UiColors.iconError,
label: 'Open', label: t.client_home.dashboard.metric_open,
value: '${totalNeeded - totalConfirmed}', value: '${totalNeeded - totalConfirmed}',
valueColor: UiColors.textError, valueColor: UiColors.textError,
), ),

View File

@@ -65,6 +65,8 @@ class _ShiftOrderFormSheetState extends State<ShiftOrderFormSheet> {
dc.ListTeamHubsByOwnerIdTeamHubs? _selectedHub; dc.ListTeamHubsByOwnerIdTeamHubs? _selectedHub;
bool _showSuccess = false; bool _showSuccess = false;
Map<String, dynamic>? _submitData; Map<String, dynamic>? _submitData;
bool _isSubmitting = false;
String? _errorMessage;
@override @override
void initState() { void initState() {
@@ -190,7 +192,25 @@ class _ShiftOrderFormSheetState extends State<ShiftOrderFormSheet> {
} }
Future<void> _handleSubmit() async { Future<void> _handleSubmit() async {
await _submitNewOrder(); if (_isSubmitting) return;
setState(() {
_isSubmitting = true;
_errorMessage = null;
});
try {
await _submitNewOrder();
} catch (e) {
if (!mounted) return;
setState(() {
_isSubmitting = false;
_errorMessage = 'Failed to create order. Please try again.';
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(_errorMessage!)),
);
}
} }
Future<void> _submitNewOrder() async { Future<void> _submitNewOrder() async {
@@ -296,6 +316,7 @@ class _ShiftOrderFormSheetState extends State<ShiftOrderFormSheet> {
'date': _dateController.text, 'date': _dateController.text,
}; };
_showSuccess = true; _showSuccess = true;
_isSubmitting = false;
}); });
} }
@@ -770,7 +791,7 @@ class _ShiftOrderFormSheetState extends State<ShiftOrderFormSheet> {
UiButton.primary( UiButton.primary(
text: widget.initialData != null ? 'Update Order' : 'Post Order', text: widget.initialData != null ? 'Update Order' : 'Post Order',
onPressed: widget.isLoading ? null : _handleSubmit, onPressed: (widget.isLoading || _isSubmitting) ? null : _handleSubmit,
), ),
SizedBox(height: MediaQuery.of(context).padding.bottom + UiConstants.space5), SizedBox(height: MediaQuery.of(context).padding.bottom + UiConstants.space5),
], ],

View File

@@ -78,7 +78,7 @@ class SpendingWidget extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text( Text(
'This Week', t.client_home.dashboard.spending.this_week,
style: UiTypography.footnote2r.white.copyWith( style: UiTypography.footnote2r.white.copyWith(
color: UiColors.white.withValues(alpha: 0.7), color: UiColors.white.withValues(alpha: 0.7),
fontSize: 9, fontSize: 9,
@@ -93,7 +93,7 @@ class SpendingWidget extends StatelessWidget {
), ),
), ),
Text( Text(
'$weeklyShifts shifts', t.client_home.dashboard.spending.shifts_count(count: weeklyShifts),
style: UiTypography.footnote2r.white.copyWith( style: UiTypography.footnote2r.white.copyWith(
color: UiColors.white.withValues(alpha: 0.6), color: UiColors.white.withValues(alpha: 0.6),
fontSize: 9, fontSize: 9,
@@ -107,7 +107,7 @@ class SpendingWidget extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: <Widget>[ children: <Widget>[
Text( Text(
'Next 7 Days', t.client_home.dashboard.spending.next_7_days,
style: UiTypography.footnote2r.white.copyWith( style: UiTypography.footnote2r.white.copyWith(
color: UiColors.white.withValues(alpha: 0.7), color: UiColors.white.withValues(alpha: 0.7),
fontSize: 9, fontSize: 9,
@@ -122,7 +122,7 @@ class SpendingWidget extends StatelessWidget {
), ),
), ),
Text( Text(
'$next7DaysScheduled scheduled', t.client_home.dashboard.spending.scheduled_count(count: next7DaysScheduled),
style: UiTypography.footnote2r.white.copyWith( style: UiTypography.footnote2r.white.copyWith(
color: UiColors.white.withValues(alpha: 0.6), color: UiColors.white.withValues(alpha: 0.6),
fontSize: 9, fontSize: 9,

View File

@@ -3,7 +3,6 @@ library;
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 'package:krow_data_connect/krow_data_connect.dart';
import 'package:firebase_auth/firebase_auth.dart' as firebase;
import 'src/data/repositories_impl/hub_repository_impl.dart'; import 'src/data/repositories_impl/hub_repository_impl.dart';
import 'src/domain/repositories/hub_repository_interface.dart'; import 'src/domain/repositories/hub_repository_interface.dart';
import 'src/domain/usecases/assign_nfc_tag_usecase.dart'; import 'src/domain/usecases/assign_nfc_tag_usecase.dart';
@@ -23,12 +22,7 @@ class ClientHubsModule extends Module {
@override @override
void binds(Injector i) { void binds(Injector i) {
// Repositories // Repositories
i.addLazySingleton<HubRepositoryInterface>( i.addLazySingleton<HubRepositoryInterface>(HubRepositoryImpl.new);
() => HubRepositoryImpl(
firebaseAuth: firebase.FirebaseAuth.instance,
dataConnect: ExampleConnector.instance,
),
);
// UseCases // UseCases
i.addLazySingleton(GetHubsUseCase.new); i.addLazySingleton(GetHubsUseCase.new);
@@ -37,14 +31,7 @@ class ClientHubsModule extends Module {
i.addLazySingleton(AssignNfcTagUseCase.new); i.addLazySingleton(AssignNfcTagUseCase.new);
// BLoCs // BLoCs
i.add<ClientHubsBloc>( i.add<ClientHubsBloc>(ClientHubsBloc.new);
() => ClientHubsBloc(
getHubsUseCase: i.get<GetHubsUseCase>(),
createHubUseCase: i.get<CreateHubUseCase>(),
deleteHubUseCase: i.get<DeleteHubUseCase>(),
assignNfcTagUseCase: i.get<AssignNfcTagUseCase>(),
),
);
} }
@override @override

View File

@@ -9,30 +9,26 @@ import 'package:krow_domain/krow_domain.dart' as domain;
import 'package:krow_domain/krow_domain.dart' import 'package:krow_domain/krow_domain.dart'
show show
HubHasOrdersException, HubHasOrdersException,
HubCreationFailedException,
BusinessNotFoundException, BusinessNotFoundException,
NotAuthenticatedException; NotAuthenticatedException;
import '../../domain/repositories/hub_repository_interface.dart'; import '../../domain/repositories/hub_repository_interface.dart';
/// Implementation of [HubRepositoryInterface] backed by Data Connect. /// Implementation of [HubRepositoryInterface] backed by Data Connect.
class HubRepositoryImpl class HubRepositoryImpl implements HubRepositoryInterface {
with dc.DataErrorHandler HubRepositoryImpl({required dc.DataConnectService service})
implements HubRepositoryInterface { : _service = service;
HubRepositoryImpl({
required firebase.FirebaseAuth firebaseAuth,
required dc.ExampleConnector dataConnect,
}) : _firebaseAuth = firebaseAuth,
_dataConnect = dataConnect;
final firebase.FirebaseAuth _firebaseAuth; final dc.DataConnectService _service;
final dc.ExampleConnector _dataConnect;
@override @override
Future<List<domain.Hub>> getHubs() async { Future<List<domain.Hub>> getHubs() async {
final dc.GetBusinessesByUserIdBusinesses business = await _getBusinessForCurrentUser(); return _service.run(() async {
final String teamId = await _getOrCreateTeamId(business); final dc.GetBusinessesByUserIdBusinesses business =
return _fetchHubsForTeam(teamId: teamId, businessId: business.id); await _getBusinessForCurrentUser();
final String teamId = await _getOrCreateTeamId(business);
return _fetchHubsForTeam(teamId: teamId, businessId: business.id);
});
} }
@override @override
@@ -48,101 +44,98 @@ class HubRepositoryImpl
String? country, String? country,
String? zipCode, String? zipCode,
}) async { }) async {
final dc.GetBusinessesByUserIdBusinesses business = await _getBusinessForCurrentUser(); return _service.run(() async {
final String teamId = await _getOrCreateTeamId(business); final dc.GetBusinessesByUserIdBusinesses business =
final _PlaceAddress? placeAddress = await _getBusinessForCurrentUser();
placeId == null || placeId.isEmpty ? null : await _fetchPlaceAddress(placeId); final String teamId = await _getOrCreateTeamId(business);
final String? cityValue = city ?? placeAddress?.city ?? business.city; final _PlaceAddress? placeAddress = placeId == null || placeId.isEmpty
final String? stateValue = state ?? placeAddress?.state; ? null
final String? streetValue = street ?? placeAddress?.street; : await _fetchPlaceAddress(placeId);
final String? countryValue = country ?? placeAddress?.country; final String? cityValue = city ?? placeAddress?.city ?? business.city;
final String? zipCodeValue = zipCode ?? placeAddress?.zipCode; final String? stateValue = state ?? placeAddress?.state;
final String? streetValue = street ?? placeAddress?.street;
final String? countryValue = country ?? placeAddress?.country;
final String? zipCodeValue = zipCode ?? placeAddress?.zipCode;
final OperationResult<dc.CreateTeamHubData, dc.CreateTeamHubVariables> final OperationResult<dc.CreateTeamHubData, dc.CreateTeamHubVariables>
result = await executeProtected(() => _dataConnect 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) final String createdId = result.data.teamHub_insert.id;
.country(countryValue)
.zipCode(zipCodeValue)
.execute());
final String createdId = result.data.teamHub_insert.id;
final List<domain.Hub> hubs = await _fetchHubsForTeam( final List<domain.Hub> hubs = await _fetchHubsForTeam(
teamId: teamId, teamId: teamId,
businessId: business.id, businessId: business.id,
); );
domain.Hub? createdHub; domain.Hub? createdHub;
for (final domain.Hub hub in hubs) { for (final domain.Hub hub in hubs) {
if (hub.id == createdId) { if (hub.id == createdId) {
createdHub = hub; createdHub = hub;
break; break;
}
} }
} return createdHub ??
return createdHub ?? domain.Hub(
domain.Hub( id: createdId,
id: createdId, businessId: business.id,
businessId: business.id, name: name,
name: name, address: address,
address: address, nfcTagId: null,
nfcTagId: null, status: domain.HubStatus.active,
status: domain.HubStatus.active, );
); });
} }
@override @override
Future<void> deleteHub(String id) async { Future<void> deleteHub(String id) async {
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id; return _service.run(() async {
if (businessId == null || businessId.isEmpty) { final String businessId = await _service.getBusinessId();
await _firebaseAuth.signOut();
throw const BusinessNotFoundException(
technicalMessage: 'Business ID missing from session',
);
}
final QueryResult<dc.ListOrdersByBusinessAndTeamHubData, final QueryResult<
dc.ListOrdersByBusinessAndTeamHubVariables> result = dc.ListOrdersByBusinessAndTeamHubData,
await executeProtected(() => _dataConnect 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(
technicalMessage: 'Hub $id has ${result.data.orders.length} orders', technicalMessage: 'Hub $id has ${result.data.orders.length} orders',
); );
} }
await executeProtected(() => _dataConnect.deleteTeamHub(id: id).execute()); await _service.connector.deleteTeamHub(id: id).execute();
});
} }
@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) {
return dc.GetBusinessesByUserIdBusinesses( return dc.GetBusinessesByUserIdBusinesses(
id: cachedBusiness.id, id: cachedBusiness.id,
businessName: cachedBusiness.businessName, businessName: cachedBusiness.businessName,
userId: _firebaseAuth.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,
@@ -159,31 +152,32 @@ class HubRepositoryImpl
); );
} }
final firebase.User? user = _firebaseAuth.currentUser; final firebase.User? user = _service.auth.currentUser;
if (user == null) { if (user == null) {
throw const NotAuthenticatedException( throw const NotAuthenticatedException(
technicalMessage: 'No Firebase user in currentUser', technicalMessage: 'No Firebase user in currentUser',
); );
} }
final QueryResult<dc.GetBusinessesByUserIdData, final QueryResult<
dc.GetBusinessesByUserIdVariables> result = dc.GetBusinessesByUserIdData,
await executeProtected(() => _dataConnect.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 _firebaseAuth.signOut(); await _service.auth.signOut();
throw BusinessNotFoundException( throw BusinessNotFoundException(
technicalMessage: 'No business found for user ${user.uid}', technicalMessage: 'No business found for user ${user.uid}',
); );
} }
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,
@@ -203,26 +197,26 @@ class HubRepositoryImpl
dc.GetBusinessesByUserIdBusinesses business, dc.GetBusinessesByUserIdBusinesses business,
) async { ) async {
final QueryResult<dc.GetTeamsByOwnerIdData, dc.GetTeamsByOwnerIdVariables> final QueryResult<dc.GetTeamsByOwnerIdData, dc.GetTeamsByOwnerIdVariables>
teamsResult = await executeProtected(() => _dataConnect.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 = _dataConnect.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 executeProtected(() => createTeamBuilder.execute());
final String teamId = createTeamResult.data.team_insert.id; final String teamId = createTeamResult.data.team_insert.id;
return teamId; return teamId;
@@ -232,11 +226,13 @@ class HubRepositoryImpl
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 executeProtected(() => _dataConnect.getTeamHubsByTeamId( dc.GetTeamHubsByTeamIdVariables
teamId: teamId, >
).execute()); hubsResult = await _service.connector
.getTeamHubsByTeamId(teamId: teamId)
.execute();
return hubsResult.data.teamHubs return hubsResult.data.teamHubs
.map( .map(
@@ -246,10 +242,9 @@ class HubRepositoryImpl
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();
@@ -262,7 +257,7 @@ class HubRepositoryImpl
<String, String>{ <String, String>{
'place_id': placeId, 'place_id': placeId,
'fields': 'address_component', 'fields': 'address_component',
'key': AppConfig.googlePlacesApiKey, 'key': AppConfig.googleMapsApiKey,
}, },
); );
try { try {
@@ -294,7 +289,8 @@ class HubRepositoryImpl
for (final dynamic entry in components) { for (final dynamic entry in components) {
final Map<String, dynamic> component = entry as Map<String, dynamic>; final Map<String, dynamic> component = entry as Map<String, dynamic>;
final List<dynamic> types = component['types'] as List<dynamic>? ?? <dynamic>[]; final List<dynamic> types =
component['types'] as List<dynamic>? ?? <dynamic>[];
final String? longName = component['long_name'] as String?; final String? longName = component['long_name'] as String?;
final String? shortName = component['short_name'] as String?; final String? shortName = component['short_name'] as String?;

View File

@@ -227,23 +227,23 @@ class ClientHubsPage extends StatelessWidget {
} }
Future<void> _confirmDeleteHub(BuildContext context, Hub hub) async { Future<void> _confirmDeleteHub(BuildContext context, Hub hub) async {
final String hubName = hub.name.isEmpty ? 'this hub' : hub.name; final String hubName = hub.name.isEmpty ? t.client_hubs.title : hub.name;
return showDialog<void>( return showDialog<void>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (BuildContext dialogContext) { builder: (BuildContext dialogContext) {
return AlertDialog( return AlertDialog(
title: const Text('Confirm Hub Deletion'), title: Text(t.client_hubs.delete_dialog.title),
content: Column( content: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[ children: <Widget>[
Text('Are you sure you want to delete "$hubName"?'), Text(t.client_hubs.delete_dialog.message(hubName: hubName)),
const SizedBox(height: UiConstants.space2), const SizedBox(height: UiConstants.space2),
const Text('This action cannot be undone.'), Text(t.client_hubs.delete_dialog.undo_warning),
const SizedBox(height: UiConstants.space2), const SizedBox(height: UiConstants.space2),
Text( Text(
'Note that if there are any shifts/orders assigned to this hub we shouldn\'t be able to delete the hub.', t.client_hubs.delete_dialog.dependency_warning,
style: UiTypography.footnote1r.copyWith( style: UiTypography.footnote1r.copyWith(
color: UiColors.textSecondary, color: UiColors.textSecondary,
), ),
@@ -253,7 +253,7 @@ class ClientHubsPage extends StatelessWidget {
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(
onPressed: () => Modular.to.pop(), onPressed: () => Modular.to.pop(),
child: const Text('Cancel'), child: Text(t.client_hubs.delete_dialog.cancel),
), ),
TextButton( TextButton(
onPressed: () { onPressed: () {
@@ -265,7 +265,7 @@ class ClientHubsPage extends StatelessWidget {
style: TextButton.styleFrom( style: TextButton.styleFrom(
foregroundColor: UiColors.destructive, foregroundColor: UiColors.destructive,
), ),
child: const Text('Delete'), child: Text(t.client_hubs.delete_dialog.delete),
), ),
], ],
); );

View File

@@ -25,7 +25,7 @@ class HubAddressAutocomplete extends StatelessWidget {
return GooglePlaceAutoCompleteTextField( return GooglePlaceAutoCompleteTextField(
textEditingController: controller, textEditingController: controller,
focusNode: focusNode, focusNode: focusNode,
googleAPIKey: AppConfig.googlePlacesApiKey, googleAPIKey: AppConfig.googleMapsApiKey,
debounceTime: 500, debounceTime: 500,
countries: HubsConstants.supportedCountries, countries: HubsConstants.supportedCountries,
isLatLngRequired: true, isLatLngRequired: true,

View File

@@ -1,6 +1,6 @@
import 'package:firebase_auth/firebase_auth.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 'src/data/repositories_impl/settings_repository_impl.dart'; import 'src/data/repositories_impl/settings_repository_impl.dart';
import 'src/domain/repositories/settings_repository_interface.dart'; import 'src/domain/repositories/settings_repository_interface.dart';
import 'src/domain/usecases/sign_out_usecase.dart'; import 'src/domain/usecases/sign_out_usecase.dart';
@@ -9,20 +9,19 @@ import 'src/presentation/pages/client_settings_page.dart';
/// A [Module] for the client settings feature. /// A [Module] for the client settings feature.
class ClientSettingsModule extends Module { class ClientSettingsModule extends Module {
@override
List<Module> get imports => <Module>[DataConnectModule()];
@override @override
void binds(Injector i) { void binds(Injector i) {
// Repositories // Repositories
i.addLazySingleton<SettingsRepositoryInterface>( i.addLazySingleton<SettingsRepositoryInterface>(SettingsRepositoryImpl.new);
() => SettingsRepositoryImpl(firebaseAuth: FirebaseAuth.instance),
);
// UseCases // UseCases
i.addLazySingleton(SignOutUseCase.new); i.addLazySingleton(SignOutUseCase.new);
// BLoCs // BLoCs
i.add<ClientSettingsBloc>( i.add<ClientSettingsBloc>(ClientSettingsBloc.new);
() => ClientSettingsBloc(signOutUseCase: i.get<SignOutUseCase>()),
);
} }
@override @override

View File

@@ -1,23 +1,21 @@
import 'package:firebase_auth/firebase_auth.dart'; import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import '../../domain/repositories/settings_repository_interface.dart'; import '../../domain/repositories/settings_repository_interface.dart';
/// Implementation of [SettingsRepositoryInterface]. /// Implementation of [SettingsRepositoryInterface].
/// ///
/// This implementation delegates authentication operations to [FirebaseAuth]. /// This implementation delegates authentication operations to [DataConnectService].
class SettingsRepositoryImpl implements SettingsRepositoryInterface { class SettingsRepositoryImpl implements SettingsRepositoryInterface {
/// Creates a [SettingsRepositoryImpl] with the required [_firebaseAuth]. /// Creates a [SettingsRepositoryImpl] with the required [_service].
const SettingsRepositoryImpl({required this.firebaseAuth}); const SettingsRepositoryImpl({required dc.DataConnectService service}) : _service = service;
/// The Firebase Auth instance. /// The Data Connect service.
final FirebaseAuth firebaseAuth; final dc.DataConnectService _service;
@override @override
Future<void> signOut() async { Future<void> signOut() async {
try { return _service.run(() async {
await firebaseAuth.signOut(); await _service.auth.signOut();
} catch (e) { });
throw Exception('Error signing out: ${e.toString()}');
}
} }
} }

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
import 'package:firebase_auth/firebase_auth.dart' as firebase;
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc; import 'package:krow_data_connect/krow_data_connect.dart' as dc;
@@ -6,151 +5,132 @@ import 'package:krow_domain/krow_domain.dart' as domain;
import '../../domain/repositories/i_view_orders_repository.dart'; import '../../domain/repositories/i_view_orders_repository.dart';
/// Implementation of [IViewOrdersRepository] using Data Connect. /// Implementation of [IViewOrdersRepository] using Data Connect.
class ViewOrdersRepositoryImpl class ViewOrdersRepositoryImpl implements IViewOrdersRepository {
with dc.DataErrorHandler final dc.DataConnectService _service;
implements IViewOrdersRepository {
final firebase.FirebaseAuth _firebaseAuth;
final dc.ExampleConnector _dataConnect;
ViewOrdersRepositoryImpl({ ViewOrdersRepositoryImpl({
required firebase.FirebaseAuth firebaseAuth, required dc.DataConnectService service,
required dc.ExampleConnector dataConnect, }) : _service = service;
}) : _firebaseAuth = firebaseAuth,
_dataConnect = dataConnect;
@override @override
Future<List<domain.OrderItem>> getOrdersForRange({ Future<List<domain.OrderItem>> getOrdersForRange({
required DateTime start, required DateTime start,
required DateTime end, required DateTime end,
}) async { }) async {
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id; return _service.run(() async {
if (businessId == null || businessId.isEmpty) { final String businessId = await _service.getBusinessId();
await _firebaseAuth.signOut();
throw Exception('Business is missing. Please sign in again.');
}
final fdc.Timestamp startTimestamp = _toTimestamp(_startOfDay(start));
final fdc.Timestamp endTimestamp = _toTimestamp(_endOfDay(end));
final fdc.QueryResult<dc.ListShiftRolesByBusinessAndDateRangeData,
dc.ListShiftRolesByBusinessAndDateRangeVariables> result =
await executeProtected(() => _dataConnect
.listShiftRolesByBusinessAndDateRange(
businessId: businessId,
start: startTimestamp,
end: endTimestamp,
)
.execute());
print(
'ViewOrders range start=${start.toIso8601String()} end=${end.toIso8601String()} shiftRoles=${result.data.shiftRoles.length}',
);
final String businessName =
dc.ClientSessionStore.instance.session?.business?.businessName ??
'Your Company';
return result.data.shiftRoles.map((dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole) {
final DateTime? shiftDate = shiftRole.shift.date?.toDateTime().toLocal();
final String dateStr = shiftDate == null
? ''
: DateFormat('yyyy-MM-dd').format(shiftDate);
final String startTime = _formatTime(shiftRole.startTime);
final String endTime = _formatTime(shiftRole.endTime);
final int filled = shiftRole.assigned ?? 0;
final int workersNeeded = shiftRole.count;
final double hours = shiftRole.hours ?? 0;
final double totalValue = shiftRole.totalValue ?? 0;
final double hourlyRate = _hourlyRate(shiftRole.totalValue, shiftRole.hours);
// final String status = filled >= workersNeeded ? 'filled' : 'open';
final String status = shiftRole.shift.status?.stringValue ?? 'OPEN';
final fdc.Timestamp startTimestamp = _service.toTimestamp(_startOfDay(start));
final fdc.Timestamp endTimestamp = _service.toTimestamp(_endOfDay(end));
final fdc.QueryResult<dc.ListShiftRolesByBusinessAndDateRangeData,
dc.ListShiftRolesByBusinessAndDateRangeVariables> result =
await _service.connector
.listShiftRolesByBusinessAndDateRange(
businessId: businessId,
start: startTimestamp,
end: endTimestamp,
)
.execute();
print( print(
'ViewOrders item: date=$dateStr status=$status shiftId=${shiftRole.shiftId} ' 'ViewOrders range start=${start.toIso8601String()} end=${end.toIso8601String()} shiftRoles=${result.data.shiftRoles.length}',
'roleId=${shiftRole.roleId} start=${shiftRole.startTime?.toJson()} '
'end=${shiftRole.endTime?.toJson()} hours=$hours totalValue=$totalValue',
); );
final String eventName = final String businessName =
shiftRole.shift.order.eventName ?? shiftRole.shift.title; dc.ClientSessionStore.instance.session?.business?.businessName ?? 'Your Company';
return domain.OrderItem( return result.data.shiftRoles.map((dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole) {
id: _shiftRoleKey(shiftRole.shiftId, shiftRole.roleId), final DateTime? shiftDate = shiftRole.shift.date?.toDateTime().toLocal();
orderId: shiftRole.shift.order.id, final String dateStr = shiftDate == null ? '' : DateFormat('yyyy-MM-dd').format(shiftDate);
title: '${shiftRole.role.name} - $eventName', final String startTime = _formatTime(shiftRole.startTime);
clientName: businessName, final String endTime = _formatTime(shiftRole.endTime);
status: status, final int filled = shiftRole.assigned ?? 0;
date: dateStr, final int workersNeeded = shiftRole.count;
startTime: startTime, final double hours = shiftRole.hours ?? 0;
endTime: endTime, final double totalValue = shiftRole.totalValue ?? 0;
location: shiftRole.shift.location ?? '', final double hourlyRate = _hourlyRate(shiftRole.totalValue, shiftRole.hours);
locationAddress: shiftRole.shift.locationAddress ?? '', // final String status = filled >= workersNeeded ? 'filled' : 'open';
filled: filled, final String status = shiftRole.shift.status?.stringValue ?? 'OPEN';
workersNeeded: workersNeeded,
hourlyRate: hourlyRate, print(
hours: hours, 'ViewOrders item: date=$dateStr status=$status shiftId=${shiftRole.shiftId} '
totalValue: totalValue, 'roleId=${shiftRole.roleId} start=${shiftRole.startTime?.toJson()} '
confirmedApps: const <Map<String, dynamic>>[], 'end=${shiftRole.endTime?.toJson()} hours=$hours totalValue=$totalValue',
); );
}).toList();
final String eventName = shiftRole.shift.order.eventName ?? shiftRole.shift.title;
return domain.OrderItem(
id: _shiftRoleKey(shiftRole.shiftId, shiftRole.roleId),
orderId: shiftRole.shift.order.id,
title: '${shiftRole.role.name} - $eventName',
clientName: businessName,
status: status,
date: dateStr,
startTime: startTime,
endTime: endTime,
location: shiftRole.shift.location ?? '',
locationAddress: shiftRole.shift.locationAddress ?? '',
filled: filled,
workersNeeded: workersNeeded,
hourlyRate: hourlyRate,
hours: hours,
totalValue: totalValue,
confirmedApps: const <Map<String, dynamic>>[],
);
}).toList();
});
} }
@override @override
Future<Map<String, List<Map<String, dynamic>>>> getAcceptedApplicationsForDay( Future<Map<String, List<Map<String, dynamic>>>> getAcceptedApplicationsForDay(
DateTime day, DateTime day,
) async { ) async {
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id; return _service.run(() async {
if (businessId == null || businessId.isEmpty) { final String businessId = await _service.getBusinessId();
await _firebaseAuth.signOut();
throw Exception('Business is missing. Please sign in again.');
}
final fdc.Timestamp dayStart = _toTimestamp(_startOfDay(day)); final fdc.Timestamp dayStart = _service.toTimestamp(_startOfDay(day));
final fdc.Timestamp dayEnd = _toTimestamp(_endOfDay(day)); final fdc.Timestamp dayEnd = _service.toTimestamp(_endOfDay(day));
final fdc.QueryResult<dc.ListAcceptedApplicationsByBusinessForDayData, final fdc.QueryResult<dc.ListAcceptedApplicationsByBusinessForDayData,
dc.ListAcceptedApplicationsByBusinessForDayVariables> result = dc.ListAcceptedApplicationsByBusinessForDayVariables> result =
await executeProtected(() => _dataConnect await _service.connector
.listAcceptedApplicationsByBusinessForDay( .listAcceptedApplicationsByBusinessForDay(
businessId: businessId, businessId: businessId,
dayStart: dayStart, dayStart: dayStart,
dayEnd: dayEnd, dayEnd: dayEnd,
) )
.execute()); .execute();
print(
'ViewOrders day=${day.toIso8601String()} applications=${result.data.applications.length}',
);
final Map<String, List<Map<String, dynamic>>> grouped = <String, List<Map<String, dynamic>>>{};
for (final dc.ListAcceptedApplicationsByBusinessForDayApplications application in result.data.applications) {
print( print(
'ViewOrders app: shiftId=${application.shiftId} roleId=${application.roleId} ' 'ViewOrders day=${day.toIso8601String()} applications=${result.data.applications.length}',
'checkIn=${application.checkInTime?.toJson()} checkOut=${application.checkOutTime?.toJson()}',
); );
final String key = _shiftRoleKey(application.shiftId, application.roleId);
grouped.putIfAbsent(key, () => <Map<String, dynamic>>[]); final Map<String, List<Map<String, dynamic>>> grouped = <String, List<Map<String, dynamic>>>{};
grouped[key]!.add(<String, dynamic>{ for (final dc.ListAcceptedApplicationsByBusinessForDayApplications application
'id': application.id, in result.data.applications) {
'worker_id': application.staff.id, print(
'worker_name': application.staff.fullName, 'ViewOrders app: shiftId=${application.shiftId} roleId=${application.roleId} '
'status': 'confirmed', 'checkIn=${application.checkInTime?.toJson()} checkOut=${application.checkOutTime?.toJson()}',
'photo_url': application.staff.photoUrl, );
'phone': application.staff.phone, final String key = _shiftRoleKey(application.shiftId, application.roleId);
'rating': application.staff.averageRating, grouped.putIfAbsent(key, () => <Map<String, dynamic>>[]);
}); grouped[key]!.add(<String, dynamic>{
} 'id': application.id,
return grouped; 'worker_id': application.staff.id,
'worker_name': application.staff.fullName,
'status': 'confirmed',
'photo_url': application.staff.photoUrl,
'phone': application.staff.phone,
'rating': application.staff.averageRating,
});
}
return grouped;
});
} }
String _shiftRoleKey(String shiftId, String roleId) { String _shiftRoleKey(String shiftId, String roleId) {
return '$shiftId:$roleId'; return '$shiftId:$roleId';
} }
fdc.Timestamp _toTimestamp(DateTime dateTime) {
final DateTime utc = dateTime.toUtc();
final int seconds = utc.millisecondsSinceEpoch ~/ 1000;
final int nanoseconds = (utc.microsecondsSinceEpoch % 1000000) * 1000;
return fdc.Timestamp(nanoseconds, seconds);
}
DateTime _startOfDay(DateTime dateTime) { DateTime _startOfDay(DateTime dateTime) {
return DateTime(dateTime.year, dateTime.month, dateTime.day); return DateTime(dateTime.year, dateTime.month, dateTime.day);
} }

View File

@@ -80,23 +80,6 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
} }
} }
/// Formats the date string for display.
String _formatDate({required String dateStr}) {
try {
final DateTime date = DateTime.parse(dateStr);
final DateTime now = DateTime.now();
final DateTime today = DateTime(now.year, now.month, now.day);
final DateTime tomorrow = today.add(const Duration(days: 1));
final DateTime checkDate = DateTime(date.year, date.month, date.day);
if (checkDate == today) return 'Today';
if (checkDate == tomorrow) return 'Tomorrow';
return DateFormat('EEE, MMM d').format(date);
} catch (_) {
return dateStr;
}
}
/// Formats the time string for display. /// Formats the time string for display.
String _formatTime({required String timeStr}) { String _formatTime({required String timeStr}) {
if (timeStr.isEmpty) return ''; if (timeStr.isEmpty) return '';
@@ -279,19 +262,19 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
_buildStatItem( _buildStatItem(
icon: UiIcons.dollar, icon: UiIcons.dollar,
value: '\$${cost.round()}', value: '\$${cost.round()}',
label: 'Total', label: t.client_view_orders.card.total,
), ),
_buildStatDivider(), _buildStatDivider(),
_buildStatItem( _buildStatItem(
icon: UiIcons.clock, icon: UiIcons.clock,
value: hours.toStringAsFixed(1), value: hours.toStringAsFixed(1),
label: 'Hrs', label: t.client_view_orders.card.hrs,
), ),
_buildStatDivider(), _buildStatDivider(),
_buildStatItem( _buildStatItem(
icon: UiIcons.users, icon: UiIcons.users,
value: '${order.workersNeeded}', value: '${order.workersNeeded}',
label: 'Workers', label: t.client_create_order.one_time.workers_label,
), ),
], ],
), ),
@@ -303,14 +286,14 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
children: <Widget>[ children: <Widget>[
Expanded( Expanded(
child: _buildTimeDisplay( child: _buildTimeDisplay(
label: 'Clock In', label: t.client_view_orders.card.clock_in,
time: _formatTime(timeStr: order.startTime), time: _formatTime(timeStr: order.startTime),
), ),
), ),
const SizedBox(width: UiConstants.space3), const SizedBox(width: UiConstants.space3),
Expanded( Expanded(
child: _buildTimeDisplay( child: _buildTimeDisplay(
label: 'Clock Out', label: t.client_view_orders.card.clock_out,
time: _formatTime(timeStr: order.endTime), time: _formatTime(timeStr: order.endTime),
), ),
), ),
@@ -341,8 +324,8 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
const SizedBox(width: UiConstants.space2), const SizedBox(width: UiConstants.space2),
Text( Text(
coveragePercent == 100 coveragePercent == 100
? 'All Workers Confirmed' ? t.client_view_orders.card.all_confirmed
: '${order.workersNeeded} Workers Needed', : t.client_view_orders.card.workers_needed(count: order.workersNeeded),
style: UiTypography.body2m.textPrimary, style: UiTypography.body2m.textPrimary,
), ),
], ],
@@ -378,7 +361,7 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
Padding( Padding(
padding: const EdgeInsets.only(left: 12), padding: const EdgeInsets.only(left: 12),
child: Text( child: Text(
'+${order.confirmedApps.length - 3} more', t.client_view_orders.card.show_more_workers(count: order.confirmedApps.length - 3),
style: UiTypography.footnote2r.textSecondary, style: UiTypography.footnote2r.textSecondary,
), ),
), ),
@@ -408,13 +391,13 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[ children: <Widget>[
Text( Text(
'CONFIRMED WORKERS', t.client_view_orders.card.confirmed_workers_title,
style: UiTypography.footnote2b.textSecondary, style: UiTypography.footnote2b.textSecondary,
), ),
GestureDetector( GestureDetector(
onTap: () {}, onTap: () {},
child: Text( child: Text(
'Message All', t.client_view_orders.card.message_all,
style: UiTypography.footnote2b.copyWith( style: UiTypography.footnote2b.copyWith(
color: UiColors.primary, color: UiColors.primary,
), ),
@@ -433,7 +416,7 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
child: TextButton( child: TextButton(
onPressed: () {}, onPressed: () {},
child: Text( child: Text(
'Show ${order.confirmedApps.length - 5} more workers', t.client_view_orders.card.show_more_workers(count: order.confirmedApps.length - 5),
style: UiTypography.body2m.copyWith( style: UiTypography.body2m.copyWith(
color: UiColors.primary, color: UiColors.primary,
), ),
@@ -569,7 +552,7 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
borderRadius: UiConstants.radiusSm, borderRadius: UiConstants.radiusSm,
), ),
child: Text( child: Text(
'Checked In', t.client_view_orders.card.checked_in,
style: UiTypography.titleUppercase4m.copyWith( style: UiTypography.titleUppercase4m.copyWith(
color: UiColors.textSuccess, color: UiColors.textSuccess,
), ),
@@ -615,16 +598,16 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
context: context, context: context,
builder: (BuildContext context) { builder: (BuildContext context) {
return AlertDialog( return AlertDialog(
title: const Text('Call'), title: Text(t.client_view_orders.card.call_dialog.title),
content: Text('Do you want to call $phone?'), content: Text(t.client_view_orders.card.call_dialog.message(phone: phone)),
actions: <Widget>[ actions: <Widget>[
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(false), onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'), child: Text(t.common.cancel),
), ),
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(true), onPressed: () => Navigator.of(context).pop(true),
child: const Text('Call'), child: Text(t.client_view_orders.card.call_dialog.title),
), ),
], ],
); );
@@ -829,10 +812,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> {
final String dateText = orderDate == null final String dateText = orderDate == null
? widget.order.date ? widget.order.date
: DateFormat('yyyy-MM-dd').format(orderDate); : DateFormat('yyyy-MM-dd').format(orderDate);
final String location = firstShift.order.teamHub?.hubName ?? final String location = firstShift.order.teamHub.hubName;
firstShift.locationAddress ??
firstShift.location ??
widget.order.locationAddress;
_dateController.text = dateText; _dateController.text = dateText;
_globalLocationController.text = location; _globalLocationController.text = location;

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.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:krow_data_connect/krow_data_connect.dart';
import 'package:firebase_auth/firebase_auth.dart' as firebase;
import 'data/repositories/view_orders_repository_impl.dart'; import 'data/repositories/view_orders_repository_impl.dart';
import 'domain/repositories/i_view_orders_repository.dart'; import 'domain/repositories/i_view_orders_repository.dart';
@@ -21,24 +20,14 @@ class ViewOrdersModule extends Module {
@override @override
void binds(Injector i) { void binds(Injector i) {
// Repositories // Repositories
i.add<IViewOrdersRepository>( i.add<IViewOrdersRepository>(ViewOrdersRepositoryImpl.new);
() => ViewOrdersRepositoryImpl(
firebaseAuth: firebase.FirebaseAuth.instance,
dataConnect: ExampleConnector.instance,
),
);
// UseCases // UseCases
i.add(GetOrdersUseCase.new); i.add(GetOrdersUseCase.new);
i.add(GetAcceptedApplicationsForDayUseCase.new); i.add(GetAcceptedApplicationsForDayUseCase.new);
// BLoCs // BLoCs
i.add( i.add(ViewOrdersCubit.new);
() => ViewOrdersCubit(
getOrdersUseCase: i.get<GetOrdersUseCase>(),
getAcceptedAppsUseCase: i.get<GetAcceptedApplicationsForDayUseCase>(),
),
);
} }
@override @override

View File

@@ -10,22 +10,15 @@ import '../../domain/ui_entities/auth_mode.dart';
import '../../domain/repositories/auth_repository_interface.dart'; import '../../domain/repositories/auth_repository_interface.dart';
/// Implementation of [AuthRepositoryInterface]. /// Implementation of [AuthRepositoryInterface].
class AuthRepositoryImpl class AuthRepositoryImpl implements AuthRepositoryInterface {
with DataErrorHandler AuthRepositoryImpl() : _service = DataConnectService.instance;
implements AuthRepositoryInterface {
AuthRepositoryImpl({
required this.firebaseAuth,
required this.dataConnect,
});
final FirebaseAuth firebaseAuth; final DataConnectService _service;
final ExampleConnector dataConnect;
Completer<String?>? _pendingVerification; Completer<String?>? _pendingVerification;
@override @override
Stream<domain.User?> get currentUser => firebaseAuth 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;
} }
@@ -44,7 +37,7 @@ class AuthRepositoryImpl
final Completer<String?> completer = Completer<String?>(); final Completer<String?> completer = Completer<String?>();
_pendingVerification = completer; _pendingVerification = completer;
await firebaseAuth.verifyPhoneNumber( await _service.auth.verifyPhoneNumber(
phoneNumber: phoneNumber, phoneNumber: phoneNumber,
verificationCompleted: (PhoneAuthCredential credential) { verificationCompleted: (PhoneAuthCredential credential) {
// Skip auto-verification for test numbers to allow manual code entry // Skip auto-verification for test numbers to allow manual code entry
@@ -64,11 +57,15 @@ class AuthRepositoryImpl
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}',
),
); );
} }
} }
@@ -101,7 +98,8 @@ class AuthRepositoryImpl
@override @override
Future<void> signOut() { Future<void> signOut() {
StaffSessionStore.instance.clear(); StaffSessionStore.instance.clear();
return firebaseAuth.signOut(); _service.clearCache();
return _service.auth.signOut();
} }
/// Verifies an OTP code and returns the authenticated user. /// Verifies an OTP code and returns the authenticated user.
@@ -115,90 +113,103 @@ class AuthRepositoryImpl
verificationId: verificationId, verificationId: verificationId,
smsCode: smsCode, smsCode: smsCode,
); );
final UserCredential userCredential = await executeProtected( final UserCredential userCredential = await _service.run(() async {
() async { try {
try { return await _service.auth.signInWithCredential(credential);
return await firebaseAuth.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);
final User? firebaseUser = userCredential.user; final User? firebaseUser = userCredential.user;
if (firebaseUser == null) { if (firebaseUser == null) {
throw const domain.SignInFailedException( throw const domain.SignInFailedException(
technicalMessage: 'Phone verification failed, no Firebase user received.', technicalMessage:
'Phone verification failed, no Firebase user received.',
); );
} }
final QueryResult<GetUserByIdData, GetUserByIdVariables> response = final QueryResult<GetUserByIdData, GetUserByIdVariables> response =
await executeProtected(() => dataConnect await _service.run(
.getUserById( () => _service.connector.getUserById(id: firebaseUser.uid).execute(),
id: firebaseUser.uid, requiresAuthentication: false,
) );
.execute());
final GetUserByIdUser? user = response.data.user; final GetUserByIdUser? user = response.data.user;
GetStaffByUserIdStaffs? staffRecord; GetStaffByUserIdStaffs? staffRecord;
if (mode == AuthMode.signup) { if (mode == AuthMode.signup) {
if (user == null) { if (user == null) {
await executeProtected(() => dataConnect await _service.run(
.createUser( () => _service.connector
id: firebaseUser.uid, .createUser(id: firebaseUser.uid, role: UserBaseRole.USER)
role: UserBaseRole.USER, .userRole('STAFF')
) .execute(),
.userRole('STAFF') requiresAuthentication: false,
.execute()); );
} else { } else {
if (user.userRole != 'STAFF') { // User exists in PostgreSQL. Check if they have a STAFF profile.
await firebaseAuth.signOut(); final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
throw const domain.UnauthorizedAppException( staffResponse = await _service.run(
technicalMessage: 'User is not authorized for this app.', () => _service.connector
.getStaffByUserId(userId: firebaseUser.uid)
.execute(),
requiresAuthentication: false,
);
if (staffResponse.data.staffs.isNotEmpty) {
// If profile exists, they should use Login mode.
await _service.auth.signOut();
throw const domain.AccountExistsException(
technicalMessage:
'This user already has a staff profile. Please log in.',
); );
} }
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
staffResponse = await executeProtected(() => dataConnect // If they don't have a staff profile but they exist as BUSINESS,
.getStaffByUserId( // they are allowed to "Sign Up" for Staff.
userId: firebaseUser.uid, // We update their userRole to 'BOTH'.
) if (user.userRole == 'BUSINESS') {
.execute()); await _service.run(
if (staffResponse.data.staffs.isNotEmpty) { () => _service.connector
await firebaseAuth.signOut(); .updateUser(id: firebaseUser.uid)
throw const domain.AccountExistsException( .userRole('BOTH')
technicalMessage: 'This user already has a staff profile. Please log in.', .execute(),
requiresAuthentication: false,
); );
} }
} }
} else { } else {
if (user == null) { if (user == null) {
await firebaseAuth.signOut(); await _service.auth.signOut();
throw const domain.UserNotFoundException( throw const domain.UserNotFoundException(
technicalMessage: 'Authenticated user profile not found in database.', technicalMessage: 'Authenticated user profile not found in database.',
); );
} }
if (user.userRole != 'STAFF') { // Allow STAFF or BOTH roles to log in to the Staff App
await firebaseAuth.signOut(); if (user.userRole != 'STAFF' && user.userRole != 'BOTH') {
await _service.auth.signOut();
throw const domain.UnauthorizedAppException( throw const domain.UnauthorizedAppException(
technicalMessage: 'User is not authorized for this app.', technicalMessage: 'User is not authorized for this app.',
); );
} }
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables> final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
staffResponse = await executeProtected(() => dataConnect staffResponse = await _service.run(
.getStaffByUserId( () => _service.connector
userId: firebaseUser.uid, .getStaffByUserId(userId: firebaseUser.uid)
) .execute(),
.execute()); requiresAuthentication: false,
);
if (staffResponse.data.staffs.isEmpty) { if (staffResponse.data.staffs.isEmpty) {
await firebaseAuth.signOut(); await _service.auth.signOut();
throw const domain.UserNotFoundException( throw const domain.UserNotFoundException(
technicalMessage: 'Your account is not registered yet. Please register first.', technicalMessage:
'Your account is not registered yet. Please register first.',
); );
} }
staffRecord = staffResponse.data.staffs.first; staffRecord = staffResponse.data.staffs.first;

View File

@@ -1,6 +1,8 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import '../../domain/repositories/place_repository.dart'; import '../../domain/repositories/place_repository.dart';
class PlaceRepositoryImpl implements PlaceRepository { class PlaceRepositoryImpl implements PlaceRepository {
@@ -18,7 +20,7 @@ class PlaceRepositoryImpl implements PlaceRepository {
<String, String>{ <String, String>{
'input': query, 'input': query,
'types': '(cities)', 'types': '(cities)',
'key': AppConfig.googlePlacesApiKey, 'key': AppConfig.googleMapsApiKey,
}, },
); );

View File

@@ -1,18 +1,13 @@
import 'package:firebase_auth/firebase_auth.dart' as auth;
import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc; import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
import 'package:firebase_auth/firebase_auth.dart' as auth;
import '../../domain/repositories/profile_setup_repository.dart'; import '../../domain/repositories/profile_setup_repository.dart';
class ProfileSetupRepositoryImpl implements ProfileSetupRepository { class ProfileSetupRepositoryImpl implements ProfileSetupRepository {
final auth.FirebaseAuth _firebaseAuth; final DataConnectService _service;
final ExampleConnector _dataConnect;
ProfileSetupRepositoryImpl({ ProfileSetupRepositoryImpl() : _service = DataConnectService.instance;
required auth.FirebaseAuth firebaseAuth,
required ExampleConnector dataConnect,
}) : _firebaseAuth = firebaseAuth,
_dataConnect = dataConnect;
@override @override
Future<void> submitProfile({ Future<void> submitProfile({
@@ -23,17 +18,19 @@ class ProfileSetupRepositoryImpl implements ProfileSetupRepository {
required List<String> industries, required List<String> industries,
required List<String> skills, required List<String> skills,
}) async { }) async {
final auth.User? firebaseUser = _firebaseAuth.currentUser; return _service.run(() async {
if (firebaseUser == null) { final auth.User? firebaseUser = _service.auth.currentUser;
throw Exception('User not authenticated.'); if (firebaseUser == null) {
} throw const NotAuthenticatedException(
technicalMessage: 'User not authenticated.');
}
final StaffSession? session = StaffSessionStore.instance.session; final StaffSession? session = StaffSessionStore.instance.session;
final String email = session?.user.email ?? ''; final String email = session?.user.email ?? '';
final String? phone = firebaseUser.phoneNumber; final String? phone = firebaseUser.phoneNumber;
final fdc.OperationResult<CreateStaffData, CreateStaffVariables> final fdc.OperationResult<CreateStaffData, CreateStaffVariables> result =
result = await _dataConnect await _service.connector
.createStaff( .createStaff(
userId: firebaseUser.uid, userId: firebaseUser.uid,
fullName: fullName, fullName: fullName,
@@ -63,5 +60,6 @@ class ProfileSetupRepositoryImpl implements ProfileSetupRepository {
StaffSession(user: session.user, staff: staff, ownerId: session.ownerId), StaffSession(user: session.user, staff: staff, ownerId: session.ownerId),
); );
} }
});
} }
} }

View File

@@ -20,5 +20,4 @@ abstract interface class AuthRepositoryInterface {
/// Signs out the current user. /// Signs out the current user.
Future<void> signOut(); Future<void> signOut();
// Future<Staff?> getStaffProfile(String userId); // Could be moved to a separate repository if needed, but useful here for routing logic.
} }

View File

@@ -5,7 +5,7 @@ import 'package:staff_authentication/src/domain/ui_entities/auth_mode.dart';
abstract class AuthEvent extends Equatable { abstract class AuthEvent extends Equatable {
const AuthEvent(); const AuthEvent();
@override @override
List<Object> get props => <Object>[]; List<Object?> get props => <Object?>[];
} }
/// Event for requesting a sign-in with a phone number. /// Event for requesting a sign-in with a phone number.
@@ -19,7 +19,7 @@ class AuthSignInRequested extends AuthEvent {
const AuthSignInRequested({this.phoneNumber, required this.mode}); const AuthSignInRequested({this.phoneNumber, required this.mode});
@override @override
List<Object> get props => <Object>[mode]; List<Object?> get props => <Object?>[phoneNumber, mode];
} }
/// Event for submitting an OTP (One-Time Password) for verification. /// Event for submitting an OTP (One-Time Password) for verification.
@@ -43,7 +43,7 @@ class AuthOtpSubmitted extends AuthEvent {
}); });
@override @override
List<Object> get props => <Object>[verificationId, smsCode, mode]; List<Object?> get props => <Object?>[verificationId, smsCode, mode];
} }
/// Event for clearing any authentication error in the state. /// Event for clearing any authentication error in the state.
@@ -57,7 +57,7 @@ class AuthResetRequested extends AuthEvent {
const AuthResetRequested({required this.mode}); const AuthResetRequested({required this.mode});
@override @override
List<Object> get props => <Object>[mode]; List<Object?> get props => <Object?>[mode];
} }
/// Event for ticking down the resend cooldown. /// Event for ticking down the resend cooldown.
@@ -67,7 +67,7 @@ class AuthCooldownTicked extends AuthEvent {
const AuthCooldownTicked(this.secondsRemaining); const AuthCooldownTicked(this.secondsRemaining);
@override @override
List<Object> get props => <Object>[secondsRemaining]; List<Object?> get props => <Object?>[secondsRemaining];
} }
/// Event for updating the current draft OTP in the state. /// Event for updating the current draft OTP in the state.
@@ -78,7 +78,7 @@ class AuthOtpUpdated extends AuthEvent {
const AuthOtpUpdated(this.otp); const AuthOtpUpdated(this.otp);
@override @override
List<Object> get props => <Object>[otp]; List<Object?> get props => <Object?>[otp];
} }
/// Event for updating the current draft phone number in the state. /// Event for updating the current draft phone number in the state.
@@ -89,5 +89,5 @@ class AuthPhoneUpdated extends AuthEvent {
const AuthPhoneUpdated(this.phoneNumber); const AuthPhoneUpdated(this.phoneNumber);
@override @override
List<Object> get props => <Object>[phoneNumber]; List<Object?> get props => <Object?>[phoneNumber];
} }

View File

@@ -0,0 +1,14 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
/// A simple introductory page that displays the KROW logo.
class IntroPage extends StatelessWidget {
const IntroPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(child: Image.asset(UiImageAssets.logoYellow, width: 120)),
);
}
}

View File

@@ -50,17 +50,22 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
required BuildContext context, required BuildContext context,
required String phoneNumber, required String phoneNumber,
}) { }) {
final String normalized = phoneNumber.replaceAll(RegExp(r'\\D'), ''); String normalized = phoneNumber.replaceAll(RegExp(r'\D'), '');
// Handle US numbers entered with a leading 1
if (normalized.length == 11 && normalized.startsWith('1')) {
normalized = normalized.substring(1);
}
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),
); );
@@ -73,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,
@@ -86,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
@@ -102,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) {
@@ -114,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;
@@ -147,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();
}, },
), ),
@@ -169,13 +174,13 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
verificationId: state.verificationId ?? '', verificationId: state.verificationId ?? '',
), ),
) )
: PhoneInput( : PhoneInput(
state: state, state: state,
onSendCode: (String phoneNumber) => _onSendCode( onSendCode: (String phoneNumber) => _onSendCode(
context: context, context: context,
phoneNumber: phoneNumber, phoneNumber: phoneNumber,
),
), ),
),
), ),
), ),
); );

View File

@@ -88,7 +88,7 @@ class _PhoneInputFormFieldState extends State<PhoneInputFormField> {
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
inputFormatters: <TextInputFormatter>[ inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly, FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10), LengthLimitingTextInputFormatter(11),
], ],
decoration: InputDecoration( decoration: InputDecoration(
hintText: t.staff_authentication.phone_input.hint, hintText: t.staff_authentication.phone_input.hint,

View File

@@ -2,7 +2,6 @@ import 'package:flutter/material.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 'package:krow_data_connect/krow_data_connect.dart';
import 'package:firebase_auth/firebase_auth.dart' as firebase;
import 'package:staff_authentication/src/data/repositories_impl/auth_repository_impl.dart'; import 'package:staff_authentication/src/data/repositories_impl/auth_repository_impl.dart';
import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart'; import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart';
import 'package:staff_authentication/src/domain/usecases/sign_in_with_phone_usecase.dart'; import 'package:staff_authentication/src/domain/usecases/sign_in_with_phone_usecase.dart';
@@ -15,6 +14,7 @@ import 'package:staff_authentication/src/data/repositories_impl/place_repository
import 'package:staff_authentication/src/domain/usecases/search_cities_usecase.dart'; import 'package:staff_authentication/src/domain/usecases/search_cities_usecase.dart';
import 'package:staff_authentication/src/presentation/blocs/auth_bloc.dart'; import 'package:staff_authentication/src/presentation/blocs/auth_bloc.dart';
import 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_bloc.dart'; import 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_bloc.dart';
import 'package:staff_authentication/src/presentation/pages/intro_page.dart';
import 'package:staff_authentication/src/presentation/pages/get_started_page.dart'; import 'package:staff_authentication/src/presentation/pages/get_started_page.dart';
import 'package:staff_authentication/src/presentation/pages/phone_verification_page.dart'; import 'package:staff_authentication/src/presentation/pages/phone_verification_page.dart';
import 'package:staff_authentication/src/presentation/pages/profile_setup_page.dart'; import 'package:staff_authentication/src/presentation/pages/profile_setup_page.dart';
@@ -28,18 +28,8 @@ class StaffAuthenticationModule extends Module {
@override @override
void binds(Injector i) { void binds(Injector i) {
// Repositories // Repositories
i.addLazySingleton<AuthRepositoryInterface>( i.addLazySingleton<AuthRepositoryInterface>(AuthRepositoryImpl.new);
() => AuthRepositoryImpl( i.addLazySingleton<ProfileSetupRepository>(ProfileSetupRepositoryImpl.new);
firebaseAuth: firebase.FirebaseAuth.instance,
dataConnect: ExampleConnector.instance,
),
);
i.addLazySingleton<ProfileSetupRepository>(
() => ProfileSetupRepositoryImpl(
firebaseAuth: firebase.FirebaseAuth.instance,
dataConnect: ExampleConnector.instance,
),
);
i.addLazySingleton<PlaceRepository>(PlaceRepositoryImpl.new); i.addLazySingleton<PlaceRepository>(PlaceRepositoryImpl.new);
// UseCases // UseCases
@@ -65,7 +55,8 @@ class StaffAuthenticationModule extends Module {
@override @override
void routes(RouteManager r) { void routes(RouteManager r) {
r.child(StaffPaths.root, child: (_) => const GetStartedPage()); r.child(StaffPaths.root, child: (_) => const IntroPage());
r.child(StaffPaths.getStarted, child: (_) => const GetStartedPage());
r.child( r.child(
StaffPaths.phoneVerification, StaffPaths.phoneVerification,
child: (BuildContext context) { child: (BuildContext context) {

Some files were not shown because too many files have changed in this diff Show More