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"
- "**/*.freezed.dart"
- "**/*.config.dart"
- "apps/mobile/prototypes/**"
errors:
# Set the severity of the always_specify_types rule to warning as requested.
always_specify_types: warning

View File

@@ -1,3 +1,5 @@
import java.util.Base64
plugins {
id("com.android.application")
id("kotlin-android")
@@ -6,6 +8,18 @@ plugins {
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 {
namespace = "com.krowwithus.client"
compileSdk = flutter.compileSdkVersion
@@ -29,6 +43,8 @@ android {
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartEnvironmentVariables["GOOGLE_MAPS_API_KEY"] ?: ""
}
buildTypes {

View File

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

View File

@@ -1,5 +1,6 @@
import Flutter
import UIKit
import GoogleMaps
@main
@objc class AppDelegate: FlutterAppDelegate {
@@ -7,7 +8,31 @@ import UIKit
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
if let apiKey = getDartDefine(key: "GOOGLE_MAPS_API_KEY") {
GMSServices.provideAPIKey(apiKey)
}
GeneratedPluginRegistrant.register(with: self)
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/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>DART_DEFINES</key>
<string>$(DART_DEFINES)</string>
</dict>
</plist>

View File

@@ -14,8 +14,10 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'firebase_options.dart';
import 'src/widgets/session_listener.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -29,7 +31,17 @@ void main() async {
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.

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:
sdk: flutter
firebase_core: ^4.4.0
krow_data_connect: ^0.0.1
dev_dependencies:
flutter_test:

View File

@@ -1,3 +1,5 @@
import java.util.Base64
plugins {
id("com.android.application")
id("kotlin-android")
@@ -6,6 +8,18 @@ plugins {
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 {
namespace = "com.krowwithus.staff"
compileSdk = flutter.compileSdkVersion
@@ -29,6 +43,8 @@ android {
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartEnvironmentVariables["GOOGLE_MAPS_API_KEY"] ?: ""
}
buildTypes {

View File

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

View File

@@ -30,11 +30,21 @@ public final class GeneratedPluginRegistrant {
} catch (Exception 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 {
flutterEngine.getPlugins().add(new com.baseflow.geolocator.GeolocatorPlugin());
} catch (Exception 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 {
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
} catch (Exception e) {
@@ -50,5 +60,10 @@ public final class GeneratedPluginRegistrant {
} catch (Exception 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 UIKit
import GoogleMaps
@main
@objc class AppDelegate: FlutterAppDelegate {
@@ -7,7 +8,31 @@ import UIKit
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
if let apiKey = getDartDefine(key: "GOOGLE_MAPS_API_KEY") {
GMSServices.provideAPIKey(apiKey)
}
GeneratedPluginRegistrant.register(with: self)
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;
#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>)
#import <permission_handler_apple/PermissionHandlerPlugin.h>
#else
@@ -42,6 +48,12 @@
@import shared_preferences_foundation;
#endif
#if __has_include(<url_launcher_ios/URLLauncherPlugin.h>)
#import <url_launcher_ios/URLLauncherPlugin.h>
#else
@import url_launcher_ios;
#endif
@implementation GeneratedPluginRegistrant
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
@@ -49,8 +61,10 @@
[FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]];
[FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]];
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
[FLTGoogleMapsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTGoogleMapsPlugin"]];
[PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]];
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
}
@end

View File

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

View File

@@ -5,17 +5,18 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_data_connect/krow_data_connect.dart';
import 'package:krowwithus_staff/firebase_options.dart';
import 'package:staff_authentication/staff_authentication.dart'
as staff_authentication;
import 'package:staff_main/staff_main.dart' as staff_main;
import 'package:krow_core/core.dart';
import 'src/widgets/session_listener.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
// Register global BLoC observer for centralized error logging
Bloc.observer = CoreBlocObserver(
@@ -23,7 +24,17 @@ void main() async {
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.
@@ -34,7 +45,10 @@ class AppModule extends Module {
@override
void routes(RouteManager r) {
// Set the initial route to the authentication module
r.module(StaffPaths.root, module: staff_authentication.StaffAuthenticationModule());
r.module(
StaffPaths.root,
module: staff_authentication.StaffAuthenticationModule(),
);
r.module(StaffPaths.main, module: staff_main.StaffMainModule());
}

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 <url_launcher_linux/url_launcher_plugin.h>
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
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -10,6 +10,7 @@ import firebase_auth
import firebase_core
import geolocator_apple
import shared_preferences_foundation
import url_launcher_macos
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
@@ -17,4 +18,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
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
krow_core:
path: ../../packages/core
krow_data_connect:
path: ../../packages/data_connect
cupertino_icons: ^1.0.8
flutter_modular: ^6.3.0
firebase_core: ^4.4.0

View File

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

View File

@@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
firebase_core
geolocator_windows
permission_handler_windows
url_launcher_windows
)
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 {
AppConfig._();
/// The Google Places API key used for address autocomplete functionality.
static const String googlePlacesApiKey = String.fromEnvironment('GOOGLE_PLACES_API_KEY');
/// The Google Maps API key.
static const String googleMapsApiKey = String.fromEnvironment('GOOGLE_MAPS_API_KEY');
}

View File

@@ -21,7 +21,7 @@ import 'route_paths.dart';
///
/// See also:
/// * [ClientPaths] for route path constants
/// * [StaffNavigator] for Staff app navigation
/// * [ClientNavigator] for Client app navigation
extension ClientNavigator on IModularNavigator {
// ==========================================================================
// AUTHENTICATION FLOWS
@@ -35,6 +35,13 @@ extension ClientNavigator on IModularNavigator {
navigate(ClientPaths.root);
}
/// Navigates to the get started page.
///
/// This is the landing page for unauthenticated users, offering login/signup options.
void toClientGetStartedPage() {
navigate(ClientPaths.getStarted);
}
/// Navigates to the client sign-in page.
///
/// This page allows existing clients to log in using email/password

View File

@@ -41,6 +41,11 @@ class ClientPaths {
/// This serves as the entry point for unauthenticated users.
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.
///
/// 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.
/// Used when signing out or session expires.
void toGetStarted() {
void toInitialPage() {
navigate(StaffPaths.root);
}
/// Navigates to the get started page.
///
/// This is the landing page for unauthenticated users, offering login/signup options.
void toGetStartedPage() {
navigate(StaffPaths.getStarted);
}
/// Navigates to the phone verification page.
///
/// Used for both login and signup flows to verify phone numbers via OTP.

View File

@@ -41,6 +41,11 @@ class StaffPaths {
/// This serves as the entry point for unauthenticated staff members.
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).
///
/// 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",
"drag_instruction": "Drag to reorder, toggle visibility",
"reset": "Reset",
"todays_coverage": "TODAY'S COVERAGE",
"percent_covered": "$percent% Covered",
"metric_needed": "Needed",
"metric_filled": "Filled",
"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",
"insight_lightbulb": "Save $amount/month",
"insight_tip": "Book 48hrs ahead for better rates"
@@ -237,6 +245,14 @@
"scan_button": "Scan NFC Tag",
"tag_identified": "Tag Identified",
"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": {
@@ -337,14 +353,26 @@
"cancelled": "CANCELLED",
"get_direction": "Get direction",
"total": "Total",
"hrs": "HRS",
"hrs": "Hrs",
"workers": "$count workers",
"clock_in": "CLOCK IN",
"clock_out": "CLOCK OUT",
"coverage": "Coverage",
"workers_label": "$filled/$needed Workers",
"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": {
@@ -498,6 +526,10 @@
"menu_items": {
"personal_info": "Personal Info",
"emergency_contact": "Emergency Contact",
"emergency_contact_page": {
"save_success": "Emergency contacts saved successfully",
"save_continue": "Save & Continue"
},
"experience": "Experience",
"attire": "Attire",
"documents": "Documents",
@@ -853,6 +885,7 @@
},
"staff_certificates": {
"title": "Certificates",
"error_loading": "Error loading certificates",
"progress": {
"title": "Your Progress",
"verified_count": "$completed of $total verified",
@@ -966,6 +999,14 @@
"est_total": "Est. Total",
"hours_label": "$count hours",
"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",
"job_description": "JOB DESCRIPTION",
"cancel_shift": "CANCEL SHIFT",
@@ -988,6 +1029,41 @@
"applying_dialog": {
"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": {

View File

@@ -148,9 +148,17 @@
"edit_mode_active": "Modo Edición Activo",
"drag_instruction": "Arrastra para reordenar, cambia la visibilidad",
"reset": "Restablecer",
"todays_coverage": "COBERTURA DE HOY",
"percent_covered": "$percent% Cubierto",
"metric_needed": "Necesario",
"metric_filled": "Lleno",
"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",
"insight_lightbulb": "Ahorra $amount/mes",
"insight_tip": "Reserva con 48h de antelación para mejores tarifas"
@@ -237,6 +245,14 @@
"scan_button": "Escanear Etiqueta NFC",
"tag_identified": "Etiqueta Identificada",
"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": {
@@ -337,14 +353,26 @@
"cancelled": "CANCELADO",
"get_direction": "Obtener dirección",
"total": "Total",
"hrs": "HRS",
"hrs": "Hrs",
"workers": "$count trabajadores",
"clock_in": "ENTRADA",
"clock_out": "SALIDA",
"coverage": "Cobertura",
"workers_label": "$filled/$needed Trabajadores",
"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": {
@@ -498,6 +526,10 @@
"menu_items": {
"personal_info": "Información Personal",
"emergency_contact": "Contacto de Emergencia",
"emergency_contact_page": {
"save_success": "Contactos de emergencia guardados con éxito",
"save_continue": "Guardar y Continuar"
},
"experience": "Experiencia",
"attire": "Vestimenta",
"documents": "Documentos",
@@ -853,6 +885,7 @@
},
"staff_certificates": {
"title": "Certificados",
"error_loading": "Error al cargar certificados",
"progress": {
"title": "Tu Progreso",
"verified_count": "$completed de $total verificados",
@@ -966,6 +999,14 @@
"est_total": "Total est.",
"hours_label": "$count horas",
"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",
"job_description": "DESCRIPCIÓN DEL TRABAJO",
"cancel_shift": "CANCELAR TURNO",
@@ -988,6 +1029,41 @@
"applying_dialog": {
"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": {

View File

@@ -3,7 +3,6 @@
/// This package provides mock implementations of domain repository interfaces
/// for development and testing purposes.
///
/// TODO: These mocks currently do not implement any specific interfaces.
/// They will implement interfaces defined in feature packages once those are created.
library;
@@ -13,6 +12,8 @@ export 'src/session/client_session_store.dart';
// Export the generated Data Connect SDK
export 'src/dataconnect_generated/generated.dart';
export 'src/services/data_connect_service.dart';
export 'src/services/mixins/session_handler_mixin.dart';
export 'src/session/staff_session_store.dart';
export 'src/mixins/data_error_handler.dart';
export 'src/services/mixins/data_error_handler.dart';

View File

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

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 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:krow_domain/krow_domain.dart';
/// Mixin to handle Data Layer errors and map them to Domain Failures.
@@ -19,8 +20,12 @@ mixin DataErrorHandler {
try {
return await action().timeout(timeout);
} on TimeoutException {
debugPrint(
'DataErrorHandler: Request timed out after ${timeout.inSeconds}s',
);
throw ServiceUnavailableException(
technicalMessage: 'Request timed out after ${timeout.inSeconds}s');
technicalMessage: 'Request timed out after ${timeout.inSeconds}s',
);
} on SocketException catch (e) {
throw NetworkException(technicalMessage: 'SocketException: ${e.message}');
} on FirebaseException catch (e) {
@@ -31,16 +36,26 @@ mixin DataErrorHandler {
msg.contains('offline') ||
msg.contains('network') ||
msg.contains('connection failed')) {
debugPrint(
'DataErrorHandler: Firebase network error: ${e.code} - ${e.message}',
);
throw NetworkException(
technicalMessage: 'Firebase ${e.code}: ${e.message}');
technicalMessage: 'Firebase ${e.code}: ${e.message}',
);
}
if (code == 'deadline-exceeded') {
debugPrint(
'DataErrorHandler: Firebase timeout error: ${e.code} - ${e.message}',
);
throw ServiceUnavailableException(
technicalMessage: 'Firebase ${e.code}: ${e.message}');
technicalMessage: 'Firebase ${e.code}: ${e.message}',
);
}
debugPrint('DataErrorHandler: Firebase error: ${e.code} - ${e.message}');
// Fallback for other Firebase errors
throw ServerException(
technicalMessage: 'Firebase ${e.code}: ${e.message}');
technicalMessage: 'Firebase ${e.code}: ${e.message}',
);
} catch (e) {
final String errorStr = e.toString().toLowerCase();
if (errorStr.contains('socketexception') ||
@@ -55,6 +70,7 @@ mixin DataErrorHandler {
errorStr.contains('grpc error') ||
errorStr.contains('terminated') ||
errorStr.contains('connectexception')) {
debugPrint('DataErrorHandler: Network-related error: $e');
throw NetworkException(technicalMessage: e.toString());
}
@@ -62,7 +78,7 @@ mixin DataErrorHandler {
if (e is AppException) rethrow;
// Debugging: Log unexpected errors
print('DataErrorHandler: Unhandled exception caught: $e');
debugPrint('DataErrorHandler: Unhandled exception caught: $e');
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 {
final String id;
final String businessName;
@@ -19,15 +17,9 @@ class ClientBusinessSession {
}
class ClientSession {
final domain.User user;
final String? userPhotoUrl;
final ClientBusinessSession? business;
const ClientSession({
required this.user,
required this.userPhotoUrl,
required this.business,
});
const ClientSession({required this.business});
}
class ClientSessionStore {

View File

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

View File

@@ -15,3 +15,6 @@ dependencies:
path: ../domain
flutter_modular: ^6.3.0
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(
color: UiColors.separatorPrimary,
space: 1,
thickness: 1,
thickness: 0.5,
),
// Card Theme

View File

@@ -173,6 +173,14 @@ class UiTypography {
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)
static final TextStyle headline2m = _primaryBase.copyWith(
fontWeight: FontWeight.w500,

View File

@@ -30,6 +30,8 @@ export 'src/entities/events/work_session.dart';
// Shifts
export 'src/entities/shifts/shift.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
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/staff_payment.dart';
export 'src/entities/financial/payment_summary.dart';
export 'src/entities/financial/bank_account/bank_account.dart';
export 'src/entities/financial/bank_account/business_bank_account.dart';
export 'src/entities/financial/bank_account/staff_bank_account.dart';
export 'src/adapters/financial/bank_account/bank_account_adapter.dart';
// Profile
export 'src/entities/profile/staff_document.dart';
@@ -66,7 +72,6 @@ export 'src/entities/ratings/business_staff_preference.dart';
// Staff Profile
export 'src/entities/profile/emergency_contact.dart';
export 'src/entities/profile/bank_account.dart';
export 'src/entities/profile/accessibility.dart';
export 'src/entities/profile/schedule.dart';

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

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:krow_domain/src/entities/shifts/break/break.dart';
class Shift extends Equatable {
final String id;
@@ -29,6 +30,7 @@ class Shift extends Equatable {
final String? roleId;
final bool? hasApplied;
final double? totalValue;
final Break? breakInfo;
const Shift({
required this.id,
@@ -59,10 +61,11 @@ class Shift extends Equatable {
this.roleId,
this.hasApplied,
this.totalValue,
this.breakInfo,
});
@override
List<Object?> get props => [
List<Object?> get props => <Object?>[
id,
title,
clientName,
@@ -91,16 +94,16 @@ class Shift extends Equatable {
roleId,
hasApplied,
totalValue,
breakInfo,
];
}
class ShiftManager extends Equatable {
const ShiftManager({required this.name, required this.phone, this.avatar});
final String name;
final String phone;
final String? avatar;
const ShiftManager({required this.name, required this.phone, this.avatar});
@override
List<Object?> get props => [name, phone, avatar];
List<Object?> get props => <Object?>[name, phone, avatar];
}

View File

@@ -1,6 +1,5 @@
library;
import 'package:firebase_auth/firebase_auth.dart' as firebase;
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.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_up_with_email_use_case.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_sign_in_page.dart';
import 'src/presentation/pages/client_sign_up_page.dart';
@@ -28,12 +28,7 @@ class ClientAuthenticationModule extends Module {
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<AuthRepositoryInterface>(
() => AuthRepositoryImpl(
firebaseAuth: firebase.FirebaseAuth.instance,
dataConnect: ExampleConnector.instance,
),
);
i.addLazySingleton<AuthRepositoryInterface>(AuthRepositoryImpl.new);
// UseCases
i.addLazySingleton(
@@ -60,7 +55,8 @@ class ClientAuthenticationModule extends Module {
@override
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.signUp, child: (_) => const ClientSignUpPage());
}

View File

@@ -12,7 +12,6 @@ import 'package:krow_domain/krow_domain.dart'
AccountExistsException,
UserNotFoundException,
UnauthorizedAppException,
UnauthorizedAppException,
PasswordMismatchException,
NetworkException;
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
/// identity management and Krow's Data Connect SDK for storing user profile data.
class AuthRepositoryImpl
with dc.DataErrorHandler
implements AuthRepositoryInterface {
class AuthRepositoryImpl implements AuthRepositoryInterface {
/// Creates an [AuthRepositoryImpl] with the real dependencies.
AuthRepositoryImpl({
required firebase.FirebaseAuth firebaseAuth,
required dc.ExampleConnector dataConnect,
}) : _firebaseAuth = firebaseAuth,
_dataConnect = dataConnect;
final firebase.FirebaseAuth _firebaseAuth;
final dc.ExampleConnector _dataConnect;
AuthRepositoryImpl({dc.DataConnectService? service})
: _service = service ?? dc.DataConnectService.instance;
final dc.DataConnectService _service;
@override
Future<domain.User> signInWithEmail({
@@ -42,10 +35,8 @@ class AuthRepositoryImpl
required String password,
}) async {
try {
final firebase.UserCredential credential = await _firebaseAuth.signInWithEmailAndPassword(
email: email,
password: password,
);
final firebase.UserCredential credential = await _service.auth
.signInWithEmailAndPassword(email: email, password: password);
final firebase.User? firebaseUser = credential.user;
if (firebaseUser == null) {
@@ -59,16 +50,13 @@ class AuthRepositoryImpl
fallbackEmail: firebaseUser.email ?? email,
requireBusinessRole: true,
);
} on firebase.FirebaseAuthException catch (e) {
if (e.code == 'invalid-credential' || e.code == 'wrong-password') {
throw InvalidCredentialsException(
technicalMessage: 'Firebase error code: ${e.code}',
);
} else if (e.code == 'network-request-failed') {
throw NetworkException(
technicalMessage: 'Firebase: ${e.message}',
);
throw NetworkException(technicalMessage: 'Firebase: ${e.message}');
} else {
throw SignInFailedException(
technicalMessage: 'Firebase auth error: ${e.message}',
@@ -77,9 +65,7 @@ class AuthRepositoryImpl
} on domain.AppException {
rethrow;
} catch (e) {
throw SignInFailedException(
technicalMessage: 'Unexpected error: $e',
);
throw SignInFailedException(technicalMessage: 'Unexpected error: $e');
}
}
@@ -94,10 +80,8 @@ class AuthRepositoryImpl
try {
// Step 1: Try to create Firebase Auth user
final firebase.UserCredential credential = await _firebaseAuth.createUserWithEmailAndPassword(
email: email,
password: password,
);
final firebase.UserCredential credential = await _service.auth
.createUserWithEmailAndPassword(email: email, password: password);
firebaseUser = credential.user;
if (firebaseUser == null) {
@@ -111,14 +95,12 @@ class AuthRepositoryImpl
firebaseUser: firebaseUser,
companyName: companyName,
email: email,
onBusinessCreated: (String businessId) => createdBusinessId = businessId,
onBusinessCreated: (String businessId) =>
createdBusinessId = businessId,
);
} on firebase.FirebaseAuthException catch (e) {
if (e.code == 'weak-password') {
throw WeakPasswordException(
technicalMessage: 'Firebase: ${e.message}',
);
throw WeakPasswordException(technicalMessage: 'Firebase: ${e.message}');
} else if (e.code == 'email-already-in-use') {
// Email exists in Firebase Auth - try to sign in and complete registration
return await _handleExistingFirebaseAccount(
@@ -127,9 +109,7 @@ class AuthRepositoryImpl
companyName: companyName,
);
} else if (e.code == 'network-request-failed') {
throw NetworkException(
technicalMessage: 'Firebase: ${e.message}',
);
throw NetworkException(technicalMessage: 'Firebase: ${e.message}');
} else {
throw SignUpFailedException(
technicalMessage: 'Firebase auth error: ${e.message}',
@@ -137,14 +117,18 @@ class AuthRepositoryImpl
}
} on domain.AppException {
// Rollback for our known exceptions
await _rollbackSignUp(firebaseUser: firebaseUser, businessId: createdBusinessId);
await _rollbackSignUp(
firebaseUser: firebaseUser,
businessId: createdBusinessId,
);
rethrow;
} catch (e) {
// Rollback: Clean up any partially created resources
await _rollbackSignUp(firebaseUser: firebaseUser, businessId: createdBusinessId);
throw SignUpFailedException(
technicalMessage: 'Unexpected error: $e',
await _rollbackSignUp(
firebaseUser: firebaseUser,
businessId: createdBusinessId,
);
throw SignUpFailedException(technicalMessage: 'Unexpected error: $e');
}
}
@@ -164,14 +148,15 @@ class AuthRepositoryImpl
required String password,
required String companyName,
}) async {
developer.log('Email exists in Firebase, attempting sign-in: $email', name: 'AuthRepository');
developer.log(
'Email exists in Firebase, attempting sign-in: $email',
name: 'AuthRepository',
);
try {
// Try to sign in with the provided password
final firebase.UserCredential credential = await _firebaseAuth.signInWithEmailAndPassword(
email: email,
password: password,
);
final firebase.UserCredential credential = await _service.auth
.signInWithEmailAndPassword(email: email, password: password);
final firebase.User? firebaseUser = credential.user;
if (firebaseUser == null) {
@@ -181,28 +166,40 @@ class AuthRepositoryImpl
}
// Sign-in succeeded! Check if user already has a BUSINESS account in PostgreSQL
final bool hasBusinessAccount = await _checkBusinessUserExists(firebaseUser.uid);
final bool hasBusinessAccount = await _checkBusinessUserExists(
firebaseUser.uid,
);
if (hasBusinessAccount) {
// User already has a KROW Client account
developer.log('User already has BUSINESS account: ${firebaseUser.uid}', name: 'AuthRepository');
developer.log(
'User already has BUSINESS account: ${firebaseUser.uid}',
name: 'AuthRepository',
);
throw AccountExistsException(
technicalMessage: 'User ${firebaseUser.uid} already has BUSINESS role',
technicalMessage:
'User ${firebaseUser.uid} already has BUSINESS role',
);
}
// User exists in Firebase but not in KROW PostgreSQL - create the entities
developer.log('Creating BUSINESS account for existing Firebase user: ${firebaseUser.uid}', name: 'AuthRepository');
developer.log(
'Creating BUSINESS account for existing Firebase user: ${firebaseUser.uid}',
name: 'AuthRepository',
);
return await _createBusinessAndUser(
firebaseUser: firebaseUser,
companyName: companyName,
email: email,
onBusinessCreated: (_) {}, // No rollback needed for existing Firebase user
onBusinessCreated:
(_) {}, // No rollback needed for existing Firebase user
);
} on firebase.FirebaseAuthException catch (e) {
// Sign-in failed - check why
developer.log('Sign-in failed with code: ${e.code}', name: 'AuthRepository');
developer.log(
'Sign-in failed with code: ${e.code}',
name: 'AuthRepository',
);
if (e.code == 'wrong-password' || e.code == 'invalid-credential') {
// Password doesn't match - check what providers are available
@@ -226,9 +223,13 @@ class AuthRepositoryImpl
// We can't distinguish between "wrong password" and "no password provider"
// due to Firebase deprecating fetchSignInMethodsForEmail.
// The PasswordMismatchException message covers both scenarios.
developer.log('Password mismatch or different provider for: $email', name: 'AuthRepository');
developer.log(
'Password mismatch or different provider for: $email',
name: 'AuthRepository',
);
throw PasswordMismatchException(
technicalMessage: 'Email $email: password mismatch or different auth provider',
technicalMessage:
'Email $email: password mismatch or different auth provider',
);
}
@@ -236,9 +237,12 @@ class AuthRepositoryImpl
Future<bool> _checkBusinessUserExists(String firebaseUserId) async {
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;
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.
@@ -250,29 +254,47 @@ class AuthRepositoryImpl
}) async {
// Create Business entity in PostgreSQL
final OperationResult<dc.CreateBusinessData, dc.CreateBusinessVariables> createBusinessResponse =
await executeProtected(() => _dataConnect.createBusiness(
final OperationResult<dc.CreateBusinessData, dc.CreateBusinessVariables>
createBusinessResponse = await _service.run(
() => _service.connector
.createBusiness(
businessName: companyName,
userId: firebaseUser.uid,
rateGroup: dc.BusinessRateGroup.STANDARD,
status: dc.BusinessStatus.PENDING,
).execute());
)
.execute(),
);
final dc.CreateBusinessBusinessInsert businessData = createBusinessResponse.data.business_insert;
final dc.CreateBusinessBusinessInsert businessData =
createBusinessResponse.data.business_insert;
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 =
await executeProtected(() => _dataConnect.createUser(
id: firebaseUser.uid,
role: dc.UserBaseRole.USER,
)
if (existingUser != null) {
// User exists (likely in another app like STAFF). Update role to BOTH.
await _service.run(
() => _service.connector
.updateUser(id: firebaseUser.uid)
.userRole('BOTH')
.execute(),
);
} else {
// Create new User entity in PostgreSQL
await _service.run(
() => _service.connector
.createUser(id: firebaseUser.uid, role: dc.UserBaseRole.USER)
.email(email)
.userRole('BUSINESS')
.execute());
final dc.CreateUserUserInsert newUserData = createUserResponse.data.user_insert;
.execute(),
);
}
return _getUserProfile(
firebaseUserId: firebaseUser.uid,
@@ -288,7 +310,7 @@ class AuthRepositoryImpl
// Delete business first (if created)
if (businessId != null) {
try {
await _dataConnect.deleteBusiness(id: businessId).execute();
await _service.connector.deleteBusiness(id: businessId).execute();
} catch (_) {
// Log but don't throw - we're already in error recovery
}
@@ -306,8 +328,9 @@ class AuthRepositoryImpl
@override
Future<void> signOut() async {
try {
await _firebaseAuth.signOut();
await _service.auth.signOut();
dc.ClientSessionStore.instance.clear();
_service.clearCache();
} catch (e) {
throw Exception('Error signing out: ${e.toString()}');
}
@@ -315,7 +338,9 @@ class AuthRepositoryImpl
@override
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({
@@ -324,18 +349,25 @@ class AuthRepositoryImpl
bool requireBusinessRole = false,
}) async {
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;
if (user == null) {
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') {
await _firebaseAuth.signOut();
if (requireBusinessRole &&
user.userRole != 'BUSINESS' &&
user.userRole != 'BOTH') {
await _service.auth.signOut();
dc.ClientSessionStore.instance.clear();
_service.clearCache();
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,
);
final QueryResult<dc.GetBusinessesByUserIdData, dc.GetBusinessesByUserIdVariables> businessResponse =
await executeProtected(() => _dataConnect.getBusinessesByUserId(
userId: firebaseUserId,
).execute());
final dc.GetBusinessesByUserIdBusinesses? business = businessResponse.data.businesses.isNotEmpty
final QueryResult<
dc.GetBusinessesByUserIdData,
dc.GetBusinessesByUserIdVariables
>
businessResponse = await _service.run(
() => _service.connector
.getBusinessesByUserId(userId: firebaseUserId)
.execute(),
);
final dc.GetBusinessesByUserIdBusinesses? business =
businessResponse.data.businesses.isNotEmpty
? businessResponse.data.businesses.first
: null;
dc.ClientSessionStore.instance.setSession(
dc.ClientSession(
user: domainUser,
userPhotoUrl: user.photoUrl,
business: business == null
? null
: dc.ClientBusinessSession(

View File

@@ -27,6 +27,14 @@ class ClientGetStartedPage extends StatelessWidget {
),
SafeArea(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: IntrinsicHeight(
child: Column(
children: <Widget>[
const SizedBox(height: UiConstants.space10),
@@ -62,7 +70,11 @@ class ClientGetStartedPage extends StatelessWidget {
left: 40,
child: _WorkerProfileCard(),
),
Positioned(top: 60, right: 10, child: _CalendarCard()),
Positioned(
top: 60,
right: 10,
child: _CalendarCard(),
),
],
),
),
@@ -84,7 +96,8 @@ class ClientGetStartedPage extends StatelessWidget {
),
const SizedBox(height: UiConstants.space3),
Text(
t.client_authentication.get_started_page.subtitle,
t.client_authentication.get_started_page
.subtitle,
textAlign: TextAlign.center,
style: UiTypography.body2r.textSecondary,
),
@@ -92,9 +105,7 @@ class ClientGetStartedPage extends StatelessWidget {
// Sign In Button
UiButton.primary(
text: t
.client_authentication
.get_started_page
text: t.client_authentication.get_started_page
.sign_in_button,
onPressed: () => Modular.to.toClientSignIn(),
fullWidth: true,
@@ -104,9 +115,7 @@ class ClientGetStartedPage extends StatelessWidget {
// Create Account Button
UiButton.secondary(
text: t
.client_authentication
.get_started_page
text: t.client_authentication.get_started_page
.create_account_button,
onPressed: () => Modular.to.toClientSignUp(),
fullWidth: true,
@@ -117,6 +126,11 @@ class ClientGetStartedPage extends StatelessWidget {
],
),
),
),
);
},
),
),
],
),
);

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 ClientAuthBloc authBloc = Modular.get<ClientAuthBloc>();
return BlocProvider.value(
return BlocProvider<ClientAuthBloc>.value(
value: authBloc,
child: BlocConsumer<ClientAuthBloc, ClientAuthState>(
listener: (BuildContext context, ClientAuthState state) {

View File

@@ -1,9 +1,9 @@
import 'package:flutter_modular/flutter_modular.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 'domain/repositories/billing_repository.dart';
import 'domain/usecases/get_bank_accounts.dart';
import 'domain/usecases/get_current_bill_amount.dart';
import 'domain/usecases/get_invoice_history.dart';
import 'domain/usecases/get_pending_invoices.dart';
@@ -19,13 +19,10 @@ class BillingModule extends Module {
// Repositories
i.addSingleton<BillingRepository>(
() => BillingRepositoryImpl(
dataConnect: ExampleConnector.instance,
),
);
i.addSingleton<BillingRepository>(BillingRepositoryImpl.new);
// Use Cases
i.addSingleton(GetBankAccountsUseCase.new);
i.addSingleton(GetCurrentBillAmountUseCase.new);
i.addSingleton(GetSavingsAmountUseCase.new);
i.addSingleton(GetPendingInvoicesUseCase.new);
@@ -35,6 +32,7 @@ class BillingModule extends Module {
// BLoCs
i.addSingleton<BillingBloc>(
() => BillingBloc(
getBankAccounts: i.get<GetBankAccountsUseCase>(),
getCurrentBillAmount: i.get<GetCurrentBillAmountUseCase>(),
getSavingsAmount: i.get<GetSavingsAmountUseCase>(),
getPendingInvoices: i.get<GetPendingInvoicesUseCase>(),

View File

@@ -6,37 +6,44 @@ import '../../domain/repositories/billing_repository.dart';
/// Implementation of [BillingRepository] in the Data layer.
///
/// This class is responsible for retrieving billing data from the [FinancialRepositoryMock]
/// (which represents the Data Connect layer) and mapping it to Domain entities.
///
/// 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 {
/// This class is responsible for retrieving billing data from the
/// Data Connect layer and mapping it to Domain entities.
class BillingRepositoryImpl implements BillingRepository {
/// Creates a [BillingRepositoryImpl].
///
/// Requires the [financialRepository] to fetch financial data.
BillingRepositoryImpl({
required data_connect.ExampleConnector dataConnect,
}) : _dataConnect = dataConnect;
data_connect.DataConnectService? service,
}) : _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.
@override
@override
Future<double> getCurrentBillAmount() async {
final String? businessId =
data_connect.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return 0.0;
}
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData, data_connect.ListInvoicesByBusinessIdVariables> result = await executeProtected(() => _dataConnect
final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData,
data_connect.ListInvoicesByBusinessIdVariables> result =
await _service.connector
.listInvoicesByBusinessId(businessId: businessId)
.execute());
.execute();
return result.data.invoices
.map(_mapInvoice)
@@ -45,40 +52,39 @@ class BillingRepositoryImpl
0.0,
(double sum, Invoice item) => sum + item.totalAmount,
);
});
}
/// Fetches the history of paid invoices.
@override
Future<List<Invoice>> getInvoiceHistory() async {
final String? businessId =
data_connect.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return <Invoice>[];
}
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData, data_connect.ListInvoicesByBusinessIdVariables> result = await executeProtected(() => _dataConnect
final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData,
data_connect.ListInvoicesByBusinessIdVariables> result =
await _service.connector
.listInvoicesByBusinessId(
businessId: businessId,
)
.limit(10)
.execute());
.execute();
return result.data.invoices.map(_mapInvoice).toList();
});
}
/// Fetches pending invoices (Open or Disputed).
@override
@override
Future<List<Invoice>> getPendingInvoices() async {
final String? businessId =
data_connect.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return <Invoice>[];
}
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData, data_connect.ListInvoicesByBusinessIdVariables> result = await executeProtected(() => _dataConnect
final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData,
data_connect.ListInvoicesByBusinessIdVariables> result =
await _service.connector
.listInvoicesByBusinessId(businessId: businessId)
.execute());
.execute();
return result.data.invoices
.map(_mapInvoice)
@@ -88,6 +94,7 @@ class BillingRepositoryImpl
i.status == InvoiceStatus.disputed,
)
.toList();
});
}
/// Fetches the estimated savings amount.
@@ -101,11 +108,8 @@ class BillingRepositoryImpl
/// Fetches the breakdown of spending.
@override
Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period) async {
final String? businessId =
data_connect.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return <InvoiceItem>[];
}
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final DateTime now = DateTime.now();
final DateTime start;
@@ -118,19 +122,23 @@ class BillingRepositoryImpl
now.day,
).subtract(Duration(days: daysFromMonday));
start = DateTime(monday.year, monday.month, monday.day);
end = DateTime(monday.year, monday.month, monday.day + 6, 23, 59, 59, 999);
end = DateTime(
monday.year, monday.month, monday.day + 6, 23, 59, 59, 999);
} else {
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
final fdc.QueryResult<
data_connect.ListShiftRolesByBusinessAndDatesSummaryData,
data_connect.ListShiftRolesByBusinessAndDatesSummaryVariables>
result = await _service.connector
.listShiftRolesByBusinessAndDatesSummary(
businessId: businessId,
start: _toTimestamp(start),
end: _toTimestamp(end),
start: _service.toTimestamp(start),
end: _service.toTimestamp(end),
)
.execute());
.execute();
final List<data_connect.ListShiftRolesByBusinessAndDatesSummaryShiftRoles>
shiftRoles = result.data.shiftRoles;
@@ -139,7 +147,8 @@ class BillingRepositoryImpl
}
final Map<String, _RoleSummary> summary = <String, _RoleSummary>{};
for (final data_connect.ListShiftRolesByBusinessAndDatesSummaryShiftRoles role
for (final data_connect
.ListShiftRolesByBusinessAndDatesSummaryShiftRoles role
in shiftRoles) {
final String roleId = role.roleId;
final String roleName = role.role.name;
@@ -173,14 +182,7 @@ class BillingRepositoryImpl
),
)
.toList();
}
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);
});
}
Invoice _mapInvoice(data_connect.ListInvoicesByBusinessIdInvoices invoice) {
@@ -193,7 +195,19 @@ class BillingRepositoryImpl
workAmount: invoice.amount,
addonsAmount: invoice.otherCharges ?? 0,
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.
/// It allows the Domain layer to remain independent of specific data sources.
abstract class BillingRepository {
/// Fetches bank accounts associated with the business.
Future<List<BusinessBankAccount>> getBankAccounts();
/// Fetches invoices that are pending approval or payment.
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:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/usecases/get_bank_accounts.dart';
import '../../domain/usecases/get_current_bill_amount.dart';
import '../../domain/usecases/get_invoice_history.dart';
import '../../domain/usecases/get_pending_invoices.dart';
@@ -16,12 +17,14 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
with BlocErrorHandler<BillingState> {
/// Creates a [BillingBloc] with the given use cases.
BillingBloc({
required GetBankAccountsUseCase getBankAccounts,
required GetCurrentBillAmountUseCase getCurrentBillAmount,
required GetSavingsAmountUseCase getSavingsAmount,
required GetPendingInvoicesUseCase getPendingInvoices,
required GetInvoiceHistoryUseCase getInvoiceHistory,
required GetSpendingBreakdownUseCase getSpendingBreakdown,
}) : _getCurrentBillAmount = getCurrentBillAmount,
}) : _getBankAccounts = getBankAccounts,
_getCurrentBillAmount = getCurrentBillAmount,
_getSavingsAmount = getSavingsAmount,
_getPendingInvoices = getPendingInvoices,
_getInvoiceHistory = getInvoiceHistory,
@@ -31,6 +34,7 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
on<BillingPeriodChanged>(_onPeriodChanged);
}
final GetBankAccountsUseCase _getBankAccounts;
final GetCurrentBillAmountUseCase _getCurrentBillAmount;
final GetSavingsAmountUseCase _getSavingsAmount;
final GetPendingInvoicesUseCase _getPendingInvoices;
@@ -52,13 +56,15 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
_getPendingInvoices.call(),
_getInvoiceHistory.call(),
_getSpendingBreakdown.call(state.period),
_getBankAccounts.call(),
]);
final double currentBill = results[0] as double;
final double savings = results[1] as double;
final List<Invoice> pendingInvoices = results[2] as List<Invoice>;
final List<Invoice> invoiceHistory = results[3] as List<Invoice>;
final List<InvoiceItem> spendingItems = results[4] as List<InvoiceItem>;
final List<BusinessBankAccount> bankAccounts =
results[5] as List<BusinessBankAccount>;
// Map Domain Entities to Presentation Models
final List<BillingInvoice> uiPendingInvoices =
@@ -80,6 +86,7 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
pendingInvoices: uiPendingInvoices,
invoiceHistory: uiInvoiceHistory,
spendingBreakdown: uiSpendingBreakdown,
bankAccounts: bankAccounts,
),
);
},

View File

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

View File

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

View File

@@ -1,65 +1,33 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:flutter/material.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/billing_bloc.dart';
import '../blocs/billing_state.dart';
/// Card showing the current payment method.
class PaymentMethodCard extends StatefulWidget {
class PaymentMethodCard extends StatelessWidget {
/// Creates a [PaymentMethodCard].
const PaymentMethodCard({super.key});
@override
State<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
Widget build(BuildContext context) {
return FutureBuilder<dc.GetAccountsByOwnerIdData?>(
future: _accountsFuture,
builder:
(
BuildContext context,
AsyncSnapshot<dc.GetAccountsByOwnerIdData?> snapshot,
) {
final List<dc.GetAccountsByOwnerIdAccounts> accounts =
snapshot.data?.accounts ?? <dc.GetAccountsByOwnerIdAccounts>[];
final dc.GetAccountsByOwnerIdAccounts? account = accounts.isNotEmpty
? accounts.first
: null;
return BlocBuilder<BillingBloc, BillingState>(
builder: (BuildContext context, BillingState state) {
final List<BusinessBankAccount> accounts = state.bankAccounts;
final BusinessBankAccount? account =
accounts.isNotEmpty ? accounts.first : null;
if (account == null) {
return const SizedBox.shrink();
}
final String bankLabel = account.bank.isNotEmpty == true
? account.bank
: '----';
final String last4 = account.last4.isNotEmpty == true
? account.last4
: '----';
final bool isPrimary = account.isPrimary ?? false;
final String bankLabel =
account.bankName.isNotEmpty == true ? account.bankName : '----';
final String last4 =
account.last4.isNotEmpty == true ? account.last4 : '----';
final bool isPrimary = account.isPrimary;
final String expiryLabel = _formatExpiry(account.expiryTime);
return Container(
@@ -154,13 +122,12 @@ class _PaymentMethodCardState extends State<PaymentMethodCard> {
);
}
String _formatExpiry(fdc.Timestamp? expiryTime) {
String _formatExpiry(DateTime? expiryTime) {
if (expiryTime == null) {
return 'N/A';
}
final DateTime date = expiryTime.toDateTime();
final String month = date.month.toString().padLeft(2, '0');
final String year = (date.year % 100).toString().padLeft(2, '0');
final String month = expiryTime.month.toString().padLeft(2, '0');
final String year = (expiryTime.year % 100).toString().padLeft(2, '0');
return '$month/$year';
}
}

View File

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

View File

@@ -15,44 +15,36 @@ import '../../domain/repositories/coverage_repository.dart';
/// - Returns domain entities from `domain/ui_entities`.
class CoverageRepositoryImpl implements CoverageRepository {
/// Creates a [CoverageRepositoryImpl].
CoverageRepositoryImpl({required dc.ExampleConnector dataConnect})
: _dataConnect = dataConnect;
CoverageRepositoryImpl({required dc.DataConnectService service}) : _service = service;
final dc.ExampleConnector _dataConnect;
final dc.DataConnectService _service;
/// Fetches shifts for a specific date.
@override
Future<List<CoverageShift>> getShiftsForDate({required DateTime date}) async {
try {
final String? businessId =
dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return <CoverageShift>[];
}
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final DateTime start = DateTime(date.year, date.month, date.day);
final DateTime end =
DateTime(date.year, date.month, date.day, 23, 59, 59, 999);
final DateTime end = DateTime(date.year, date.month, date.day, 23, 59, 59, 999);
final fdc.QueryResult<
dc.ListShiftRolesByBusinessAndDateRangeData,
final fdc.QueryResult<dc.ListShiftRolesByBusinessAndDateRangeData,
dc.ListShiftRolesByBusinessAndDateRangeVariables> shiftRolesResult =
await _dataConnect
await _service.connector
.listShiftRolesByBusinessAndDateRange(
businessId: businessId,
start: _toTimestamp(start),
end: _toTimestamp(end),
start: _service.toTimestamp(start),
end: _service.toTimestamp(end),
)
.execute();
final fdc.QueryResult<
dc.ListStaffsApplicationsByBusinessForDayData,
final fdc.QueryResult<dc.ListStaffsApplicationsByBusinessForDayData,
dc.ListStaffsApplicationsByBusinessForDayVariables> applicationsResult =
await _dataConnect
await _service.connector
.listStaffsApplicationsByBusinessForDay(
businessId: businessId,
dayStart: _toTimestamp(start),
dayEnd: _toTimestamp(end),
dayStart: _service.toTimestamp(start),
dayEnd: _service.toTimestamp(end),
)
.execute();
@@ -61,18 +53,7 @@ class CoverageRepositoryImpl implements CoverageRepository {
applicationsResult.data.applications,
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.
@@ -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<dc.ListShiftRolesByBusinessAndDateRangeShiftRoles> shiftRoles,
List<dc.ListStaffsApplicationsByBusinessForDayApplications> applications,
@@ -151,10 +124,12 @@ class CoverageRepositoryImpl implements CoverageRepository {
shiftId: app.shiftId,
roleId: app.roleId,
title: app.shiftRole.role.name,
location: app.shiftRole.shift.location ?? '',
startTime: '00:00',
workersNeeded: 0,
date: date,
location: app.shiftRole.shift.location ??
app.shiftRole.shift.locationAddress ??
'',
startTime: _formatTime(app.shiftRole.startTime) ?? '00:00',
workersNeeded: app.shiftRole.count,
date: app.shiftRole.shift.date?.toDateTime() ?? date,
workers: <CoverageWorker>[],
);

View File

@@ -2,7 +2,6 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.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 'domain/repositories/client_create_order_repository_interface.dart';
import 'domain/usecases/create_one_time_order_usecase.dart';
@@ -29,12 +28,7 @@ class ClientCreateOrderModule extends Module {
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<ClientCreateOrderRepositoryInterface>(
() => ClientCreateOrderRepositoryImpl(
firebaseAuth: firebase.FirebaseAuth.instance,
dataConnect: ExampleConnector.instance,
),
);
i.addLazySingleton<ClientCreateOrderRepositoryInterface>(ClientCreateOrderRepositoryImpl.new);
// UseCases
i.addLazySingleton(GetOrderTypesUseCase.new);
@@ -44,12 +38,7 @@ class ClientCreateOrderModule extends Module {
// BLoCs
i.add<ClientCreateOrderBloc>(ClientCreateOrderBloc.new);
i.add<RapidOrderBloc>(RapidOrderBloc.new);
i.add<OneTimeOrderBloc>(
() => OneTimeOrderBloc(
i.get<CreateOneTimeOrderUseCase>(),
ExampleConnector.instance,
),
);
i.add<OneTimeOrderBloc>(OneTimeOrderBloc.new);
}
@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:intl/intl.dart';
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].
///
/// This implementation coordinates data access for order creation by delegating
/// to the [OrderRepositoryMock] and [ExampleConnector] from the shared
/// This implementation coordinates data access for order creation by [DataConnectService] from the shared
/// Data Connect package.
///
/// It follows the KROW Clean Architecture by keeping the data layer focused
/// on delegation and data mapping, without business logic.
class ClientCreateOrderRepositoryImpl
with dc.DataErrorHandler
implements ClientCreateOrderRepositoryInterface {
class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInterface {
ClientCreateOrderRepositoryImpl({
required firebase.FirebaseAuth firebaseAuth,
required dc.ExampleConnector dataConnect,
}) : _firebaseAuth = firebaseAuth,
_dataConnect = dataConnect;
required dc.DataConnectService service,
}) : _service = service;
final firebase.FirebaseAuth _firebaseAuth;
final dc.ExampleConnector _dataConnect;
final dc.DataConnectService _service;
@override
Future<List<domain.OrderType>> getOrderTypes() {
return Future.value(const <domain.OrderType>[
return Future<List<domain.OrderType>>.value(const <domain.OrderType>[
domain.OrderType(
id: 'one-time',
titleKey: 'client_create_order.types.one_time',
@@ -55,11 +48,8 @@ class ClientCreateOrderRepositoryImpl
@override
Future<void> createOneTimeOrder(domain.OneTimeOrder order) async {
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
await _firebaseAuth.signOut();
throw Exception('Business is missing. Please sign in again.');
}
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final String? vendorId = order.vendorId;
if (vendorId == null || vendorId.isEmpty) {
throw Exception('Vendor is missing.');
@@ -74,9 +64,9 @@ class ClientCreateOrderRepositoryImpl
order.date.month,
order.date.day,
);
final fdc.Timestamp orderTimestamp = _toTimestamp(orderDateOnly);
final fdc.OperationResult<dc.CreateOrderData, dc.CreateOrderVariables>
orderResult = await executeProtected(() => _dataConnect
final fdc.Timestamp orderTimestamp = _service.toTimestamp(orderDateOnly);
final fdc.OperationResult<dc.CreateOrderData, dc.CreateOrderVariables> orderResult =
await _service.connector
.createOrder(
businessId: businessId,
orderType: dc.OrderType.ONE_TIME,
@@ -86,7 +76,7 @@ class ClientCreateOrderRepositoryImpl
.eventName(order.eventName)
.status(dc.OrderStatus.POSTED)
.date(orderTimestamp)
.execute());
.execute();
final String orderId = orderResult.data.order_insert.id;
@@ -97,8 +87,8 @@ class ClientCreateOrderRepositoryImpl
final String shiftTitle = 'Shift 1 ${_formatDate(order.date)}';
final double shiftCost = _calculateShiftCost(order);
final fdc.OperationResult<dc.CreateShiftData, dc.CreateShiftVariables>
shiftResult = await executeProtected(() => _dataConnect
final fdc.OperationResult<dc.CreateShiftData, dc.CreateShiftVariables> shiftResult =
await _service.connector
.createShift(title: shiftTitle, orderId: orderId)
.date(orderTimestamp)
.location(hub.name)
@@ -115,40 +105,38 @@ class ClientCreateOrderRepositoryImpl
.filled(0)
.durationDays(1)
.cost(shiftCost)
.execute());
.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 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
await _service.connector
.createShiftRole(
shiftId: shiftId,
roleId: position.role,
count: position.count,
)
.startTime(_toTimestamp(start))
.endTime(_toTimestamp(normalizedEnd))
.startTime(_service.toTimestamp(start))
.endTime(_service.toTimestamp(normalizedEnd))
.hours(hours)
.breakType(_breakDurationFromValue(position.lunchBreak))
.isBreakPaid(_isBreakPaid(position.lunchBreak))
.totalValue(totalValue)
.execute());
.execute();
}
await executeProtected(() => _dataConnect
await _service.connector
.updateOrder(id: orderId, teamHubId: hub.id)
.shifts(fdc.AnyValue(<String>[shiftId]))
.execute());
.execute();
});
}
@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) {
final String year = dateTime.year.toString().padLeft(4, '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.
class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
with BlocErrorHandler<OneTimeOrderState>, SafeBloc<OneTimeOrderEvent, OneTimeOrderState> {
OneTimeOrderBloc(this._createOneTimeOrderUseCase, this._dataConnect)
OneTimeOrderBloc(this._createOneTimeOrderUseCase, this._service)
: super(OneTimeOrderState.initial()) {
on<OneTimeOrderVendorsLoaded>(_onVendorsLoaded);
on<OneTimeOrderVendorChanged>(_onVendorChanged);
@@ -28,13 +28,13 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
_loadHubs();
}
final CreateOneTimeOrderUseCase _createOneTimeOrderUseCase;
final dc.ExampleConnector _dataConnect;
final dc.DataConnectService _service;
Future<void> _loadVendors() async {
final List<Vendor>? vendors = await handleErrorWithResult(
action: () async {
final QueryResult<dc.ListVendorsData, void> result =
await _dataConnect.listVendors().execute();
await _service.connector.listVendors().execute();
return result.data.vendors
.map(
(dc.ListVendorsVendors vendor) => Vendor(
@@ -57,7 +57,7 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
final List<OneTimeOrderRoleOption>? roles = await handleErrorWithResult(
action: () async {
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
.map(
(dc.ListRolesByVendorIdRoles role) => OneTimeOrderRoleOption(
@@ -79,13 +79,9 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
Future<void> _loadHubs() async {
final List<OneTimeOrderHubOption>? hubs = await handleErrorWithResult(
action: () async {
final String? businessId =
dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return <OneTimeOrderHubOption>[];
}
final String businessId = await _service.getBusinessId();
final QueryResult<dc.ListTeamHubsByOwnerIdData, dc.ListTeamHubsByOwnerIdVariables>
result = await _dataConnect
result = await _service.connector
.listTeamHubsByOwnerId(ownerId: businessId)
.execute();
return result.data.teamHubs

View File

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

View File

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

View File

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

View File

@@ -8,41 +8,45 @@ import '../../domain/repositories/home_repository_interface.dart';
/// 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).
class HomeRepositoryImpl implements HomeRepositoryInterface {
/// Creates a [HomeRepositoryImpl].
HomeRepositoryImpl(this._dataConnect);
final dc.ExampleConnector _dataConnect;
HomeRepositoryImpl(this._service);
final dc.DataConnectService _service;
@override
Future<HomeDashboardData> getDashboardData() async {
try {
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return const HomeDashboardData(
weeklySpending: 0,
next7DaysSpending: 0,
weeklyShifts: 0,
next7DaysScheduled: 0,
totalNeeded: 0,
totalFilled: 0,
);
}
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final DateTime now = DateTime.now();
final int daysFromMonday = now.weekday - DateTime.monday;
final DateTime monday =
DateTime(now.year, now.month, now.day).subtract(Duration(days: daysFromMonday));
final DateTime weekRangeStart = DateTime(monday.year, monday.month, monday.day);
final DateTime weekRangeEnd =
DateTime(monday.year, monday.month, monday.day + 13, 23, 59, 59, 999);
final DateTime monday = DateTime(
now.year,
now.month,
now.day,
).subtract(Duration(days: daysFromMonday));
final DateTime weekRangeStart = DateTime(
monday.year,
monday.month,
monday.day,
);
final DateTime weekRangeEnd = DateTime(
monday.year,
monday.month,
monday.day + 13,
23,
59,
59,
999,
);
final fdc.QueryResult<
dc.GetCompletedShiftsByBusinessIdData,
dc.GetCompletedShiftsByBusinessIdVariables> completedResult =
await _dataConnect
dc.GetCompletedShiftsByBusinessIdVariables
>
completedResult = await _service.connector
.getCompletedShiftsByBusinessId(
businessId: businessId,
dateFrom: _toTimestamp(weekRangeStart),
dateTo: _toTimestamp(weekRangeEnd),
dateFrom: _service.toTimestamp(weekRangeStart),
dateTo: _service.toTimestamp(weekRangeEnd),
)
.execute();
@@ -71,16 +75,25 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
}
final DateTime start = DateTime(now.year, now.month, now.day);
final DateTime end = DateTime(now.year, now.month, now.day, 23, 59, 59, 999);
final DateTime end = DateTime(
now.year,
now.month,
now.day,
23,
59,
59,
999,
);
final fdc.QueryResult<
dc.ListShiftRolesByBusinessAndDateRangeData,
dc.ListShiftRolesByBusinessAndDateRangeVariables> result =
await _dataConnect
dc.ListShiftRolesByBusinessAndDateRangeVariables
>
result = await _service.connector
.listShiftRolesByBusinessAndDateRange(
businessId: businessId,
start: _toTimestamp(start),
end: _toTimestamp(end),
start: _service.toTimestamp(start),
end: _service.toTimestamp(end),
)
.execute();
@@ -100,59 +113,80 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
totalNeeded: totalNeeded,
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
UserSessionData getUserSessionData() {
Future<UserSessionData> getUserSessionData() async {
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
final dc.ClientBusinessSession? business = session?.business;
// If session data is available, return it immediately
if (business != null) {
return UserSessionData(
businessName: session?.business?.businessName ?? '',
photoUrl: null, // Business photo isn't currently in session
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
Future<List<ReorderItem>> getRecentReorders() async {
try {
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
return const <ReorderItem>[];
}
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final DateTime now = DateTime.now();
final DateTime start = now.subtract(const Duration(days: 30));
final fdc.Timestamp startTimestamp = _toTimestamp(start);
final fdc.Timestamp endTimestamp = _toTimestamp(now);
final fdc.Timestamp startTimestamp = _service.toTimestamp(start);
final fdc.Timestamp endTimestamp = _service.toTimestamp(now);
final fdc.QueryResult<
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersData,
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersVariables> result =
await _dataConnect.listShiftRolesByBusinessDateRangeCompletedOrders(
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersVariables
>
result = await _service.connector
.listShiftRolesByBusinessDateRangeCompletedOrders(
businessId: businessId,
start: startTimestamp,
end: endTimestamp,
).execute();
)
.execute();
return result.data.shiftRoles.map((
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersShiftRoles shiftRole,
) {
final String location =
shiftRole.shift.location ??
shiftRole.shift.locationAddress ??
'';
shiftRole.shift.location ?? shiftRole.shift.locationAddress ?? '';
final String type = shiftRole.shift.order.orderType.stringValue;
return ReorderItem(
orderId: shiftRole.shift.order.id,
@@ -164,25 +198,6 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
type: type,
);
}).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();
/// Fetches the user's session data (business name and photo).
UserSessionData getUserSessionData();
Future<UserSessionData> getUserSessionData();
/// Fetches recently completed shift roles for reorder suggestions.
Future<List<ReorderItem>> getRecentReorders();

View File

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

View File

@@ -40,7 +40,7 @@ class ClientHomeBloc extends Bloc<ClientHomeEvent, ClientHomeState>
emit: emit,
action: () async {
// Get session data
final UserSessionData sessionData = _getUserSessionDataUseCase();
final UserSessionData sessionData = await _getUserSessionDataUseCase();
// Get dashboard data
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.
class ActionsWidget extends StatelessWidget {
/// Creates an [ActionsWidget].
const ActionsWidget({
super.key,
@@ -12,6 +11,7 @@ class ActionsWidget extends StatelessWidget {
required this.onCreateOrderPressed,
this.subtitle,
});
/// Callback when RAPID is pressed.
final VoidCallback onRapidPressed;
@@ -26,12 +26,9 @@ class ActionsWidget extends StatelessWidget {
// Check if client_home exists in t
final TranslationsClientHomeActionsEn i18n = t.client_home.actions;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
return Row(
spacing: UiConstants.space4,
children: <Widget>[
Row(
children: <Widget>[
/// TODO: FEATURE_NOT_YET_IMPLEMENTED
Expanded(
child: _ActionCard(
title: i18n.rapid,
@@ -46,7 +43,6 @@ class ActionsWidget extends StatelessWidget {
onTap: onRapidPressed,
),
),
// const SizedBox(width: UiConstants.space2),
Expanded(
child: _ActionCard(
title: i18n.create_order,
@@ -62,14 +58,11 @@ class ActionsWidget extends StatelessWidget {
),
),
],
),
],
);
}
}
class _ActionCard extends StatelessWidget {
const _ActionCard({
required this.title,
required this.subtitle,

View File

@@ -22,8 +22,8 @@ class CoverageDashboard extends StatelessWidget {
int totalConfirmed = 0;
double todayCost = 0;
for (final s in shifts) {
final int needed = s['workersNeeded'] as int? ?? 0;
for (final dynamic s in shifts) {
final int needed = (s as Map<String, dynamic>)['workersNeeded'] as int? ?? 0;
final int confirmed = s['filled'] as int? ?? 0;
final double rate = s['hourlyRate'] 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 checkedInCount = applications
.where((a) => (a as Map)['checkInTime'] != null)
.where((dynamic a) => (a as Map<String, dynamic>)['checkInTime'] != null)
.length;
final int lateWorkersCount = applications
.where((a) => (a as Map)['status'] == 'LATE')
.where((dynamic a) => (a as Map<String, dynamic>)['status'] == 'LATE')
.length;
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:flutter/material.dart';
@@ -47,7 +48,7 @@ class CoverageWidget extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
"TODAY'S COVERAGE",
t.client_home.dashboard.todays_coverage,
style: UiTypography.footnote1b.copyWith(
color: UiColors.textPrimary,
letterSpacing: 0.5,
@@ -65,7 +66,7 @@ class CoverageWidget extends StatelessWidget {
borderRadius: UiConstants.radiusLg,
),
child: Text(
'$coveragePercent% Covered',
t.client_home.dashboard.percent_covered(percent: coveragePercent),
style: UiTypography.footnote2b.copyWith(color: textColor),
),
),
@@ -81,7 +82,7 @@ class CoverageWidget extends StatelessWidget {
child: _MetricCard(
icon: UiIcons.target,
iconColor: UiColors.primary,
label: 'Needed',
label: t.client_home.dashboard.metric_needed,
value: '$totalNeeded',
),
),
@@ -91,7 +92,7 @@ class CoverageWidget extends StatelessWidget {
child: _MetricCard(
icon: UiIcons.success,
iconColor: UiColors.iconSuccess,
label: 'Filled',
label: t.client_home.dashboard.metric_filled,
value: '$totalConfirmed',
valueColor: UiColors.textSuccess,
),
@@ -101,7 +102,7 @@ class CoverageWidget extends StatelessWidget {
child: _MetricCard(
icon: UiIcons.error,
iconColor: UiColors.iconError,
label: 'Open',
label: t.client_home.dashboard.metric_open,
value: '${totalNeeded - totalConfirmed}',
valueColor: UiColors.textError,
),

View File

@@ -65,6 +65,8 @@ class _ShiftOrderFormSheetState extends State<ShiftOrderFormSheet> {
dc.ListTeamHubsByOwnerIdTeamHubs? _selectedHub;
bool _showSuccess = false;
Map<String, dynamic>? _submitData;
bool _isSubmitting = false;
String? _errorMessage;
@override
void initState() {
@@ -190,7 +192,25 @@ class _ShiftOrderFormSheetState extends State<ShiftOrderFormSheet> {
}
Future<void> _handleSubmit() async {
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 {
@@ -296,6 +316,7 @@ class _ShiftOrderFormSheetState extends State<ShiftOrderFormSheet> {
'date': _dateController.text,
};
_showSuccess = true;
_isSubmitting = false;
});
}
@@ -770,7 +791,7 @@ class _ShiftOrderFormSheetState extends State<ShiftOrderFormSheet> {
UiButton.primary(
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),
],

View File

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

View File

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

View File

@@ -9,30 +9,26 @@ import 'package:krow_domain/krow_domain.dart' as domain;
import 'package:krow_domain/krow_domain.dart'
show
HubHasOrdersException,
HubCreationFailedException,
BusinessNotFoundException,
NotAuthenticatedException;
import '../../domain/repositories/hub_repository_interface.dart';
/// Implementation of [HubRepositoryInterface] backed by Data Connect.
class HubRepositoryImpl
with dc.DataErrorHandler
implements HubRepositoryInterface {
HubRepositoryImpl({
required firebase.FirebaseAuth firebaseAuth,
required dc.ExampleConnector dataConnect,
}) : _firebaseAuth = firebaseAuth,
_dataConnect = dataConnect;
class HubRepositoryImpl implements HubRepositoryInterface {
HubRepositoryImpl({required dc.DataConnectService service})
: _service = service;
final firebase.FirebaseAuth _firebaseAuth;
final dc.ExampleConnector _dataConnect;
final dc.DataConnectService _service;
@override
Future<List<domain.Hub>> getHubs() async {
final dc.GetBusinessesByUserIdBusinesses business = await _getBusinessForCurrentUser();
return _service.run(() async {
final dc.GetBusinessesByUserIdBusinesses business =
await _getBusinessForCurrentUser();
final String teamId = await _getOrCreateTeamId(business);
return _fetchHubsForTeam(teamId: teamId, businessId: business.id);
});
}
@override
@@ -48,10 +44,13 @@ class HubRepositoryImpl
String? country,
String? zipCode,
}) async {
final dc.GetBusinessesByUserIdBusinesses business = await _getBusinessForCurrentUser();
return _service.run(() async {
final dc.GetBusinessesByUserIdBusinesses business =
await _getBusinessForCurrentUser();
final String teamId = await _getOrCreateTeamId(business);
final _PlaceAddress? placeAddress =
placeId == null || placeId.isEmpty ? null : await _fetchPlaceAddress(placeId);
final _PlaceAddress? placeAddress = placeId == null || placeId.isEmpty
? null
: await _fetchPlaceAddress(placeId);
final String? cityValue = city ?? placeAddress?.city ?? business.city;
final String? stateValue = state ?? placeAddress?.state;
final String? streetValue = street ?? placeAddress?.street;
@@ -59,12 +58,8 @@ class HubRepositoryImpl
final String? zipCodeValue = zipCode ?? placeAddress?.zipCode;
final OperationResult<dc.CreateTeamHubData, dc.CreateTeamHubVariables>
result = await executeProtected(() => _dataConnect
.createTeamHub(
teamId: teamId,
hubName: name,
address: address,
)
result = await _service.connector
.createTeamHub(teamId: teamId, hubName: name, address: address)
.placeId(placeId)
.latitude(latitude)
.longitude(longitude)
@@ -73,7 +68,7 @@ class HubRepositoryImpl
.street(streetValue)
.country(countryValue)
.zipCode(zipCodeValue)
.execute());
.execute();
final String createdId = result.data.teamHub_insert.id;
final List<domain.Hub> hubs = await _fetchHubsForTeam(
@@ -96,26 +91,21 @@ class HubRepositoryImpl
nfcTagId: null,
status: domain.HubStatus.active,
);
});
}
@override
Future<void> deleteHub(String id) async {
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
await _firebaseAuth.signOut();
throw const BusinessNotFoundException(
technicalMessage: 'Business ID missing from session',
);
}
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final QueryResult<dc.ListOrdersByBusinessAndTeamHubData,
dc.ListOrdersByBusinessAndTeamHubVariables> result =
await executeProtected(() => _dataConnect
.listOrdersByBusinessAndTeamHub(
businessId: businessId,
teamHubId: id,
)
.execute());
final QueryResult<
dc.ListOrdersByBusinessAndTeamHubData,
dc.ListOrdersByBusinessAndTeamHubVariables
>
result = await _service.connector
.listOrdersByBusinessAndTeamHub(businessId: businessId, teamHubId: id)
.execute();
if (result.data.orders.isNotEmpty) {
throw HubHasOrdersException(
@@ -123,26 +113,29 @@ class HubRepositoryImpl
);
}
await executeProtected(() => _dataConnect.deleteTeamHub(id: id).execute());
await _service.connector.deleteTeamHub(id: id).execute();
});
}
@override
Future<void> assignNfcTag({
required String hubId,
required String nfcTagId,
}) {
throw UnimplementedError('NFC tag assignment is not supported for team hubs.');
Future<void> assignNfcTag({required String hubId, required String nfcTagId}) {
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.ClientBusinessSession? cachedBusiness = session?.business;
if (cachedBusiness != null) {
return dc.GetBusinessesByUserIdBusinesses(
id: cachedBusiness.id,
businessName: cachedBusiness.businessName,
userId: _firebaseAuth.currentUser?.uid ?? '',
rateGroup: const dc.Known<dc.BusinessRateGroup>(dc.BusinessRateGroup.STANDARD),
userId: _service.auth.currentUser?.uid ?? '',
rateGroup: const dc.Known<dc.BusinessRateGroup>(
dc.BusinessRateGroup.STANDARD,
),
status: const dc.Known<dc.BusinessStatus>(dc.BusinessStatus.ACTIVE),
contactName: cachedBusiness.contactName,
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) {
throw const NotAuthenticatedException(
technicalMessage: 'No Firebase user in currentUser',
);
}
final QueryResult<dc.GetBusinessesByUserIdData,
dc.GetBusinessesByUserIdVariables> result =
await executeProtected(() => _dataConnect.getBusinessesByUserId(
userId: user.uid,
).execute());
final QueryResult<
dc.GetBusinessesByUserIdData,
dc.GetBusinessesByUserIdVariables
>
result = await _service.connector
.getBusinessesByUserId(userId: user.uid)
.execute();
if (result.data.businesses.isEmpty) {
await _firebaseAuth.signOut();
await _service.auth.signOut();
throw BusinessNotFoundException(
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) {
dc.ClientSessionStore.instance.setSession(
dc.ClientSession(
user: session.user,
userPhotoUrl: session.userPhotoUrl,
business: dc.ClientBusinessSession(
id: business.id,
businessName: business.businessName,
@@ -203,14 +197,15 @@ class HubRepositoryImpl
dc.GetBusinessesByUserIdBusinesses business,
) async {
final QueryResult<dc.GetTeamsByOwnerIdData, dc.GetTeamsByOwnerIdVariables>
teamsResult = await executeProtected(() => _dataConnect.getTeamsByOwnerId(
ownerId: business.id,
).execute());
teamsResult = await _service.connector
.getTeamsByOwnerId(ownerId: business.id)
.execute();
if (teamsResult.data.teams.isNotEmpty) {
return teamsResult.data.teams.first.id;
}
final dc.CreateTeamVariablesBuilder createTeamBuilder = _dataConnect.createTeam(
final dc.CreateTeamVariablesBuilder createTeamBuilder = _service.connector
.createTeam(
teamName: '${business.businessName} Team',
ownerId: business.id,
ownerName: business.contactName ?? '',
@@ -221,8 +216,7 @@ class HubRepositoryImpl
}
final OperationResult<dc.CreateTeamData, dc.CreateTeamVariables>
createTeamResult =
await executeProtected(() => createTeamBuilder.execute());
createTeamResult = await createTeamBuilder.execute();
final String teamId = createTeamResult.data.team_insert.id;
return teamId;
@@ -232,11 +226,13 @@ class HubRepositoryImpl
required String teamId,
required String businessId,
}) async {
final QueryResult<dc.GetTeamHubsByTeamIdData,
dc.GetTeamHubsByTeamIdVariables> hubsResult =
await executeProtected(() => _dataConnect.getTeamHubsByTeamId(
teamId: teamId,
).execute());
final QueryResult<
dc.GetTeamHubsByTeamIdData,
dc.GetTeamHubsByTeamIdVariables
>
hubsResult = await _service.connector
.getTeamHubsByTeamId(teamId: teamId)
.execute();
return hubsResult.data.teamHubs
.map(
@@ -246,8 +242,7 @@ class HubRepositoryImpl
name: hub.hubName,
address: hub.address,
nfcTagId: null,
status:
hub.isActive
status: hub.isActive
? domain.HubStatus.active
: domain.HubStatus.inactive,
),
@@ -262,7 +257,7 @@ class HubRepositoryImpl
<String, String>{
'place_id': placeId,
'fields': 'address_component',
'key': AppConfig.googlePlacesApiKey,
'key': AppConfig.googleMapsApiKey,
},
);
try {
@@ -294,7 +289,8 @@ class HubRepositoryImpl
for (final dynamic entry in components) {
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? shortName = component['short_name'] as String?;

View File

@@ -227,23 +227,23 @@ class ClientHubsPage extends StatelessWidget {
}
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>(
context: context,
barrierDismissible: false,
builder: (BuildContext dialogContext) {
return AlertDialog(
title: const Text('Confirm Hub Deletion'),
title: Text(t.client_hubs.delete_dialog.title),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
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 Text('This action cannot be undone.'),
Text(t.client_hubs.delete_dialog.undo_warning),
const SizedBox(height: UiConstants.space2),
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(
color: UiColors.textSecondary,
),
@@ -253,7 +253,7 @@ class ClientHubsPage extends StatelessWidget {
actions: <Widget>[
TextButton(
onPressed: () => Modular.to.pop(),
child: const Text('Cancel'),
child: Text(t.client_hubs.delete_dialog.cancel),
),
TextButton(
onPressed: () {
@@ -265,7 +265,7 @@ class ClientHubsPage extends StatelessWidget {
style: TextButton.styleFrom(
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(
textEditingController: controller,
focusNode: focusNode,
googleAPIKey: AppConfig.googlePlacesApiKey,
googleAPIKey: AppConfig.googleMapsApiKey,
debounceTime: 500,
countries: HubsConstants.supportedCountries,
isLatLngRequired: true,

View File

@@ -1,6 +1,6 @@
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_modular/flutter_modular.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/domain/repositories/settings_repository_interface.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.
class ClientSettingsModule extends Module {
@override
List<Module> get imports => <Module>[DataConnectModule()];
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<SettingsRepositoryInterface>(
() => SettingsRepositoryImpl(firebaseAuth: FirebaseAuth.instance),
);
i.addLazySingleton<SettingsRepositoryInterface>(SettingsRepositoryImpl.new);
// UseCases
i.addLazySingleton(SignOutUseCase.new);
// BLoCs
i.add<ClientSettingsBloc>(
() => ClientSettingsBloc(signOutUseCase: i.get<SignOutUseCase>()),
);
i.add<ClientSettingsBloc>(ClientSettingsBloc.new);
}
@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';
/// Implementation of [SettingsRepositoryInterface].
///
/// This implementation delegates authentication operations to [FirebaseAuth].
/// This implementation delegates authentication operations to [DataConnectService].
class SettingsRepositoryImpl implements SettingsRepositoryInterface {
/// Creates a [SettingsRepositoryImpl] with the required [_firebaseAuth].
const SettingsRepositoryImpl({required this.firebaseAuth});
/// Creates a [SettingsRepositoryImpl] with the required [_service].
const SettingsRepositoryImpl({required dc.DataConnectService service}) : _service = service;
/// The Firebase Auth instance.
final FirebaseAuth firebaseAuth;
/// The Data Connect service.
final dc.DataConnectService _service;
@override
Future<void> signOut() async {
try {
await firebaseAuth.signOut();
} catch (e) {
throw Exception('Error signing out: ${e.toString()}');
}
return _service.run(() async {
await _service.auth.signOut();
});
}
}

View File

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

View File

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

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:intl/intl.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
@@ -6,53 +5,42 @@ import 'package:krow_domain/krow_domain.dart' as domain;
import '../../domain/repositories/i_view_orders_repository.dart';
/// Implementation of [IViewOrdersRepository] using Data Connect.
class ViewOrdersRepositoryImpl
with dc.DataErrorHandler
implements IViewOrdersRepository {
final firebase.FirebaseAuth _firebaseAuth;
final dc.ExampleConnector _dataConnect;
class ViewOrdersRepositoryImpl implements IViewOrdersRepository {
final dc.DataConnectService _service;
ViewOrdersRepositoryImpl({
required firebase.FirebaseAuth firebaseAuth,
required dc.ExampleConnector dataConnect,
}) : _firebaseAuth = firebaseAuth,
_dataConnect = dataConnect;
required dc.DataConnectService service,
}) : _service = service;
@override
Future<List<domain.OrderItem>> getOrdersForRange({
required DateTime start,
required DateTime end,
}) async {
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
await _firebaseAuth.signOut();
throw Exception('Business is missing. Please sign in again.');
}
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final fdc.Timestamp startTimestamp = _toTimestamp(_startOfDay(start));
final fdc.Timestamp endTimestamp = _toTimestamp(_endOfDay(end));
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 executeProtected(() => _dataConnect
await _service.connector
.listShiftRolesByBusinessAndDateRange(
businessId: businessId,
start: startTimestamp,
end: endTimestamp,
)
.execute());
.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';
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 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;
@@ -69,8 +57,7 @@ class ViewOrdersRepositoryImpl
'end=${shiftRole.endTime?.toJson()} hours=$hours totalValue=$totalValue',
);
final String eventName =
shiftRole.shift.order.eventName ?? shiftRole.shift.title;
final String eventName = shiftRole.shift.order.eventName ?? shiftRole.shift.title;
return domain.OrderItem(
id: _shiftRoleKey(shiftRole.shiftId, shiftRole.roleId),
@@ -91,36 +78,35 @@ class ViewOrdersRepositoryImpl
confirmedApps: const <Map<String, dynamic>>[],
);
}).toList();
});
}
@override
Future<Map<String, List<Map<String, dynamic>>>> getAcceptedApplicationsForDay(
DateTime day,
) async {
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
if (businessId == null || businessId.isEmpty) {
await _firebaseAuth.signOut();
throw Exception('Business is missing. Please sign in again.');
}
return _service.run(() async {
final String businessId = await _service.getBusinessId();
final fdc.Timestamp dayStart = _toTimestamp(_startOfDay(day));
final fdc.Timestamp dayEnd = _toTimestamp(_endOfDay(day));
final fdc.Timestamp dayStart = _service.toTimestamp(_startOfDay(day));
final fdc.Timestamp dayEnd = _service.toTimestamp(_endOfDay(day));
final fdc.QueryResult<dc.ListAcceptedApplicationsByBusinessForDayData,
dc.ListAcceptedApplicationsByBusinessForDayVariables> result =
await executeProtected(() => _dataConnect
await _service.connector
.listAcceptedApplicationsByBusinessForDay(
businessId: businessId,
dayStart: dayStart,
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) {
for (final dc.ListAcceptedApplicationsByBusinessForDayApplications application
in result.data.applications) {
print(
'ViewOrders app: shiftId=${application.shiftId} roleId=${application.roleId} '
'checkIn=${application.checkInTime?.toJson()} checkOut=${application.checkOutTime?.toJson()}',
@@ -138,19 +124,13 @@ class ViewOrdersRepositoryImpl
});
}
return grouped;
});
}
String _shiftRoleKey(String shiftId, String 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) {
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.
String _formatTime({required String timeStr}) {
if (timeStr.isEmpty) return '';
@@ -279,19 +262,19 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
_buildStatItem(
icon: UiIcons.dollar,
value: '\$${cost.round()}',
label: 'Total',
label: t.client_view_orders.card.total,
),
_buildStatDivider(),
_buildStatItem(
icon: UiIcons.clock,
value: hours.toStringAsFixed(1),
label: 'Hrs',
label: t.client_view_orders.card.hrs,
),
_buildStatDivider(),
_buildStatItem(
icon: UiIcons.users,
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>[
Expanded(
child: _buildTimeDisplay(
label: 'Clock In',
label: t.client_view_orders.card.clock_in,
time: _formatTime(timeStr: order.startTime),
),
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: _buildTimeDisplay(
label: 'Clock Out',
label: t.client_view_orders.card.clock_out,
time: _formatTime(timeStr: order.endTime),
),
),
@@ -341,8 +324,8 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
const SizedBox(width: UiConstants.space2),
Text(
coveragePercent == 100
? 'All Workers Confirmed'
: '${order.workersNeeded} Workers Needed',
? t.client_view_orders.card.all_confirmed
: t.client_view_orders.card.workers_needed(count: order.workersNeeded),
style: UiTypography.body2m.textPrimary,
),
],
@@ -378,7 +361,7 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
Padding(
padding: const EdgeInsets.only(left: 12),
child: Text(
'+${order.confirmedApps.length - 3} more',
t.client_view_orders.card.show_more_workers(count: order.confirmedApps.length - 3),
style: UiTypography.footnote2r.textSecondary,
),
),
@@ -408,13 +391,13 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
'CONFIRMED WORKERS',
t.client_view_orders.card.confirmed_workers_title,
style: UiTypography.footnote2b.textSecondary,
),
GestureDetector(
onTap: () {},
child: Text(
'Message All',
t.client_view_orders.card.message_all,
style: UiTypography.footnote2b.copyWith(
color: UiColors.primary,
),
@@ -433,7 +416,7 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
child: TextButton(
onPressed: () {},
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(
color: UiColors.primary,
),
@@ -569,7 +552,7 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
borderRadius: UiConstants.radiusSm,
),
child: Text(
'Checked In',
t.client_view_orders.card.checked_in,
style: UiTypography.titleUppercase4m.copyWith(
color: UiColors.textSuccess,
),
@@ -615,16 +598,16 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text('Call'),
content: Text('Do you want to call $phone?'),
title: Text(t.client_view_orders.card.call_dialog.title),
content: Text(t.client_view_orders.card.call_dialog.message(phone: phone)),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
child: Text(t.common.cancel),
),
TextButton(
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
? widget.order.date
: DateFormat('yyyy-MM-dd').format(orderDate);
final String location = firstShift.order.teamHub?.hubName ??
firstShift.locationAddress ??
firstShift.location ??
widget.order.locationAddress;
final String location = firstShift.order.teamHub.hubName;
_dateController.text = dateText;
_globalLocationController.text = location;

View File

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

View File

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

View File

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

View File

@@ -20,5 +20,4 @@ abstract interface class AuthRepositoryInterface {
/// Signs out the current user.
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 {
const AuthEvent();
@override
List<Object> get props => <Object>[];
List<Object?> get props => <Object?>[];
}
/// 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});
@override
List<Object> get props => <Object>[mode];
List<Object?> get props => <Object?>[phoneNumber, mode];
}
/// Event for submitting an OTP (One-Time Password) for verification.
@@ -43,7 +43,7 @@ class AuthOtpSubmitted extends AuthEvent {
});
@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.
@@ -57,7 +57,7 @@ class AuthResetRequested extends AuthEvent {
const AuthResetRequested({required this.mode});
@override
List<Object> get props => <Object>[mode];
List<Object?> get props => <Object?>[mode];
}
/// Event for ticking down the resend cooldown.
@@ -67,7 +67,7 @@ class AuthCooldownTicked extends AuthEvent {
const AuthCooldownTicked(this.secondsRemaining);
@override
List<Object> get props => <Object>[secondsRemaining];
List<Object?> get props => <Object?>[secondsRemaining];
}
/// Event for updating the current draft OTP in the state.
@@ -78,7 +78,7 @@ class AuthOtpUpdated extends AuthEvent {
const AuthOtpUpdated(this.otp);
@override
List<Object> get props => <Object>[otp];
List<Object?> get props => <Object?>[otp];
}
/// Event for updating the current draft phone number in the state.
@@ -89,5 +89,5 @@ class AuthPhoneUpdated extends AuthEvent {
const AuthPhoneUpdated(this.phoneNumber);
@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 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) {
BlocProvider.of<AuthBloc>(
context,
).add(
BlocProvider.of<AuthBloc>(context).add(
AuthSignInRequested(phoneNumber: '+1$normalized', mode: widget.mode),
);
} else {
UiSnackbar.show(
context,
message: t.staff_authentication.phone_verification_page.validation_error,
message:
t.staff_authentication.phone_verification_page.validation_error,
type: UiSnackbarType.error,
margin: const EdgeInsets.only(bottom: 180, left: 16, right: 16),
);
@@ -73,9 +78,7 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
required String otp,
required String verificationId,
}) {
BlocProvider.of<AuthBloc>(
context,
).add(
BlocProvider.of<AuthBloc>(context).add(
AuthOtpSubmitted(
verificationId: verificationId,
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.
void _onResend({required BuildContext context}) {
BlocProvider.of<AuthBloc>(context).add(
AuthSignInRequested(mode: widget.mode),
);
BlocProvider.of<AuthBloc>(
context,
).add(AuthSignInRequested(mode: widget.mode));
}
@override
@@ -102,8 +105,6 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
if (state.status == AuthStatus.authenticated) {
if (state.mode == AuthMode.signup) {
Modular.to.toProfileSetup();
} else {
Modular.to.toStaffHome();
}
} else if (state.status == AuthStatus.error &&
state.mode == AuthMode.signup) {
@@ -114,7 +115,11 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
context,
message: translateErrorKey(messageKey),
type: UiSnackbarType.error,
margin: const EdgeInsets.only(bottom: 180, left: 16, right: 16),
margin: const EdgeInsets.only(
bottom: 180,
left: 16,
right: 16,
),
);
Future<void>.delayed(const Duration(seconds: 5), () {
if (!mounted) return;
@@ -147,9 +152,9 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
centerTitle: true,
showBackButton: true,
onLeadingPressed: () {
BlocProvider.of<AuthBloc>(context).add(
AuthResetRequested(mode: widget.mode),
);
BlocProvider.of<AuthBloc>(
context,
).add(AuthResetRequested(mode: widget.mode));
Navigator.of(context).pop();
},
),

View File

@@ -88,7 +88,7 @@ class _PhoneInputFormFieldState extends State<PhoneInputFormField> {
keyboardType: TextInputType.phone,
inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(10),
LengthLimitingTextInputFormatter(11),
],
decoration: InputDecoration(
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:krow_core/core.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/domain/repositories/auth_repository_interface.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/presentation/blocs/auth_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/phone_verification_page.dart';
import 'package:staff_authentication/src/presentation/pages/profile_setup_page.dart';
@@ -28,18 +28,8 @@ class StaffAuthenticationModule extends Module {
@override
void binds(Injector i) {
// Repositories
i.addLazySingleton<AuthRepositoryInterface>(
() => AuthRepositoryImpl(
firebaseAuth: firebase.FirebaseAuth.instance,
dataConnect: ExampleConnector.instance,
),
);
i.addLazySingleton<ProfileSetupRepository>(
() => ProfileSetupRepositoryImpl(
firebaseAuth: firebase.FirebaseAuth.instance,
dataConnect: ExampleConnector.instance,
),
);
i.addLazySingleton<AuthRepositoryInterface>(AuthRepositoryImpl.new);
i.addLazySingleton<ProfileSetupRepository>(ProfileSetupRepositoryImpl.new);
i.addLazySingleton<PlaceRepository>(PlaceRepositoryImpl.new);
// UseCases
@@ -65,7 +55,8 @@ class StaffAuthenticationModule extends Module {
@override
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(
StaffPaths.phoneVerification,
child: (BuildContext context) {

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