Merge branch 'dev' into codex/local-dev-fixes
This commit is contained in:
@@ -6,6 +6,7 @@ analyzer:
|
|||||||
- "**/*.g.dart"
|
- "**/*.g.dart"
|
||||||
- "**/*.freezed.dart"
|
- "**/*.freezed.dart"
|
||||||
- "**/*.config.dart"
|
- "**/*.config.dart"
|
||||||
|
- "apps/mobile/prototypes/**"
|
||||||
errors:
|
errors:
|
||||||
# Set the severity of the always_specify_types rule to warning as requested.
|
# Set the severity of the always_specify_types rule to warning as requested.
|
||||||
always_specify_types: warning
|
always_specify_types: warning
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import java.util.Base64
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -6,6 +8,18 @@ plugins {
|
|||||||
id("com.google.gms.google-services")
|
id("com.google.gms.google-services")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val dartDefinesString = project.findProperty("dart-defines") as? String ?: ""
|
||||||
|
val dartEnvironmentVariables = mutableMapOf<String, String>()
|
||||||
|
dartDefinesString.split(",").forEach {
|
||||||
|
if (it.isNotEmpty()) {
|
||||||
|
val decoded = String(Base64.getDecoder().decode(it))
|
||||||
|
val components = decoded.split("=")
|
||||||
|
if (components.size == 2) {
|
||||||
|
dartEnvironmentVariables[components[0]] = components[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.krowwithus.client"
|
namespace = "com.krowwithus.client"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -29,6 +43,8 @@ android {
|
|||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
|
||||||
|
manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartEnvironmentVariables["GOOGLE_MAPS_API_KEY"] ?: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -30,6 +30,9 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.geo.API_KEY"
|
||||||
|
android:value="${GOOGLE_MAPS_API_KEY}" />
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import GoogleMaps
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
@@ -7,7 +8,31 @@ import UIKit
|
|||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
if let apiKey = getDartDefine(key: "GOOGLE_MAPS_API_KEY") {
|
||||||
|
GMSServices.provideAPIKey(apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func getDartDefine(key: String) -> String? {
|
||||||
|
guard let dartDefines = Bundle.main.infoDictionary?["DART_DEFINES"] as? String else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let defines = dartDefines.components(separatedBy: ",")
|
||||||
|
for define in defines {
|
||||||
|
guard let decodedData = Data(base64Encoded: define),
|
||||||
|
let decodedString = String(data: decodedData, encoding: .utf8) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let components = decodedString.components(separatedBy: "=")
|
||||||
|
if components.count == 2 && components[0] == key {
|
||||||
|
return components[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Krow With Us Client</string>
|
<string>Krow With Us Client</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>Krow With Us Client</string>
|
<string>Krow With Us Client</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
@@ -45,5 +45,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>DART_DEFINES</key>
|
||||||
|
<string>$(DART_DEFINES)</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ import 'package:flutter_bloc/flutter_bloc.dart';
|
|||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
|
|
||||||
import 'firebase_options.dart';
|
import 'firebase_options.dart';
|
||||||
|
import 'src/widgets/session_listener.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -28,8 +30,18 @@ void main() async {
|
|||||||
logEvents: true,
|
logEvents: true,
|
||||||
logStateChanges: false, // Set to true for verbose debugging
|
logStateChanges: false, // Set to true for verbose debugging
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Initialize session listener for Firebase Auth state changes
|
||||||
|
DataConnectService.instance.initializeAuthListener(
|
||||||
|
allowedRoles: <String>['CLIENT', 'BUSINESS', 'BOTH'], // Only allow users with CLIENT, BUSINESS, or BOTH roles
|
||||||
|
);
|
||||||
|
|
||||||
runApp(ModularApp(module: AppModule(), child: const AppWidget()));
|
runApp(
|
||||||
|
ModularApp(
|
||||||
|
module: AppModule(),
|
||||||
|
child: const SessionListener(child: AppWidget()),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The main application module for the Client app.
|
/// The main application module for the Client app.
|
||||||
|
|||||||
163
apps/mobile/apps/client/lib/src/widgets/session_listener.dart
Normal file
163
apps/mobile/apps/client/lib/src/widgets/session_listener.dart
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
|
|
||||||
|
/// A widget that listens to session state changes and handles global reactions.
|
||||||
|
///
|
||||||
|
/// This widget wraps the entire app and provides centralized session management,
|
||||||
|
/// such as logging out when the session expires or handling session errors.
|
||||||
|
class SessionListener extends StatefulWidget {
|
||||||
|
/// Creates a [SessionListener].
|
||||||
|
const SessionListener({required this.child, super.key});
|
||||||
|
|
||||||
|
/// The child widget to wrap.
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SessionListener> createState() => _SessionListenerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SessionListenerState extends State<SessionListener> {
|
||||||
|
late StreamSubscription<SessionState> _sessionSubscription;
|
||||||
|
bool _sessionExpiredDialogShown = false;
|
||||||
|
bool _isInitialState = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_setupSessionListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setupSessionListener() {
|
||||||
|
_sessionSubscription = DataConnectService.instance.onSessionStateChanged
|
||||||
|
.listen((SessionState state) {
|
||||||
|
_handleSessionChange(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
debugPrint('[SessionListener] Initialized session listener');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSessionChange(SessionState state) {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
switch (state.type) {
|
||||||
|
case SessionStateType.unauthenticated:
|
||||||
|
debugPrint(
|
||||||
|
'[SessionListener] Unauthenticated: Session expired or user logged out',
|
||||||
|
);
|
||||||
|
// On initial state (cold start), just proceed to login without dialog
|
||||||
|
// Only show dialog if user was previously authenticated (session expired)
|
||||||
|
if (_isInitialState) {
|
||||||
|
_isInitialState = false;
|
||||||
|
Modular.to.toClientGetStartedPage();
|
||||||
|
} else if (!_sessionExpiredDialogShown) {
|
||||||
|
_sessionExpiredDialogShown = true;
|
||||||
|
_showSessionExpiredDialog();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SessionStateType.authenticated:
|
||||||
|
// Session restored or user authenticated
|
||||||
|
_isInitialState = false;
|
||||||
|
_sessionExpiredDialogShown = false;
|
||||||
|
debugPrint('[SessionListener] Authenticated: ${state.userId}');
|
||||||
|
|
||||||
|
// Navigate to the main app
|
||||||
|
Modular.to.toClientHome();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SessionStateType.error:
|
||||||
|
// Show error notification with option to retry or logout
|
||||||
|
// Only show if not initial state (avoid showing on cold start)
|
||||||
|
if (!_isInitialState) {
|
||||||
|
debugPrint('[SessionListener] Session error: ${state.errorMessage}');
|
||||||
|
_showSessionErrorDialog(state.errorMessage ?? 'Session error occurred');
|
||||||
|
} else {
|
||||||
|
_isInitialState = false;
|
||||||
|
Modular.to.toClientGetStartedPage();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SessionStateType.loading:
|
||||||
|
// Session is loading, optionally show a loading indicator
|
||||||
|
debugPrint('[SessionListener] Session loading...');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows a dialog when the session expires.
|
||||||
|
void _showSessionExpiredDialog() {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Session Expired'),
|
||||||
|
content: const Text(
|
||||||
|
'Your session has expired. Please log in again to continue.',
|
||||||
|
),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_proceedToLogin();
|
||||||
|
},
|
||||||
|
child: const Text('Log In'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows a dialog when a session error occurs, with retry option.
|
||||||
|
void _showSessionErrorDialog(String errorMessage) {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Session Error'),
|
||||||
|
content: Text(errorMessage),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
// User can retry by dismissing and continuing
|
||||||
|
Modular.to.pop();
|
||||||
|
},
|
||||||
|
child: const Text('Continue'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_proceedToLogin();
|
||||||
|
},
|
||||||
|
child: const Text('Log Out'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigate to login screen and clear navigation stack.
|
||||||
|
void _proceedToLogin() {
|
||||||
|
// Clear service caches on sign-out
|
||||||
|
DataConnectService.instance.handleSignOut();
|
||||||
|
|
||||||
|
// Navigate to authentication
|
||||||
|
Modular.to.toClientGetStartedPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_sessionSubscription.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => widget.child;
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ dependencies:
|
|||||||
flutter_localizations:
|
flutter_localizations:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
firebase_core: ^4.4.0
|
firebase_core: ^4.4.0
|
||||||
|
krow_data_connect: ^0.0.1
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import java.util.Base64
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("com.android.application")
|
id("com.android.application")
|
||||||
id("kotlin-android")
|
id("kotlin-android")
|
||||||
@@ -6,6 +8,18 @@ plugins {
|
|||||||
id("com.google.gms.google-services")
|
id("com.google.gms.google-services")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val dartDefinesString = project.findProperty("dart-defines") as? String ?: ""
|
||||||
|
val dartEnvironmentVariables = mutableMapOf<String, String>()
|
||||||
|
dartDefinesString.split(",").forEach {
|
||||||
|
if (it.isNotEmpty()) {
|
||||||
|
val decoded = String(Base64.getDecoder().decode(it))
|
||||||
|
val components = decoded.split("=")
|
||||||
|
if (components.size == 2) {
|
||||||
|
dartEnvironmentVariables[components[0]] = components[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.krowwithus.staff"
|
namespace = "com.krowwithus.staff"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
@@ -29,6 +43,8 @@ android {
|
|||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
|
||||||
|
manifestPlaceholders["GOOGLE_MAPS_API_KEY"] = dartEnvironmentVariables["GOOGLE_MAPS_API_KEY"] ?: ""
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
|||||||
@@ -30,6 +30,9 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="flutterEmbedding"
|
android:name="flutterEmbedding"
|
||||||
android:value="2" />
|
android:value="2" />
|
||||||
|
<meta-data
|
||||||
|
android:name="com.google.android.geo.API_KEY"
|
||||||
|
android:value="${GOOGLE_MAPS_API_KEY}" />
|
||||||
</application>
|
</application>
|
||||||
<!-- Required to query activities that can process text, see:
|
<!-- Required to query activities that can process text, see:
|
||||||
https://developer.android.com/training/package-visibility and
|
https://developer.android.com/training/package-visibility and
|
||||||
|
|||||||
@@ -30,11 +30,21 @@ public final class GeneratedPluginRegistrant {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error registering plugin firebase_core, io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin", e);
|
Log.e(TAG, "Error registering plugin firebase_core, io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin", e);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
flutterEngine.getPlugins().add(new com.baseflow.geolocator.GeolocatorPlugin());
|
flutterEngine.getPlugins().add(new com.baseflow.geolocator.GeolocatorPlugin());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error registering plugin geolocator_android, com.baseflow.geolocator.GeolocatorPlugin", e);
|
Log.e(TAG, "Error registering plugin geolocator_android, com.baseflow.geolocator.GeolocatorPlugin", e);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new io.flutter.plugins.googlemaps.GoogleMapsPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin google_maps_flutter_android, io.flutter.plugins.googlemaps.GoogleMapsPlugin", e);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
|
flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -50,5 +60,10 @@ public final class GeneratedPluginRegistrant {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
Log.e(TAG, "Error registering plugin shared_preferences_android, io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin", e);
|
Log.e(TAG, "Error registering plugin shared_preferences_android, io.flutter.plugins.sharedpreferences.SharedPreferencesPlugin", e);
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
flutterEngine.getPlugins().add(new io.flutter.plugins.urllauncher.UrlLauncherPlugin());
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Error registering plugin url_launcher_android, io.flutter.plugins.urllauncher.UrlLauncherPlugin", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
import UIKit
|
import UIKit
|
||||||
|
import GoogleMaps
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
@@ -7,7 +8,31 @@ import UIKit
|
|||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
|
if let apiKey = getDartDefine(key: "GOOGLE_MAPS_API_KEY") {
|
||||||
|
GMSServices.provideAPIKey(apiKey)
|
||||||
|
}
|
||||||
|
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func getDartDefine(key: String) -> String? {
|
||||||
|
guard let dartDefines = Bundle.main.infoDictionary?["DART_DEFINES"] as? String else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let defines = dartDefines.components(separatedBy: ",")
|
||||||
|
for define in defines {
|
||||||
|
guard let decodedData = Data(base64Encoded: define),
|
||||||
|
let decodedString = String(data: decodedData, encoding: .utf8) else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
let components = decodedString.components(separatedBy: "=")
|
||||||
|
if components.count == 2 && components[0] == key {
|
||||||
|
return components[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,12 @@
|
|||||||
@import geolocator_apple;
|
@import geolocator_apple;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if __has_include(<google_maps_flutter_ios/FLTGoogleMapsPlugin.h>)
|
||||||
|
#import <google_maps_flutter_ios/FLTGoogleMapsPlugin.h>
|
||||||
|
#else
|
||||||
|
@import google_maps_flutter_ios;
|
||||||
|
#endif
|
||||||
|
|
||||||
#if __has_include(<permission_handler_apple/PermissionHandlerPlugin.h>)
|
#if __has_include(<permission_handler_apple/PermissionHandlerPlugin.h>)
|
||||||
#import <permission_handler_apple/PermissionHandlerPlugin.h>
|
#import <permission_handler_apple/PermissionHandlerPlugin.h>
|
||||||
#else
|
#else
|
||||||
@@ -42,6 +48,12 @@
|
|||||||
@import shared_preferences_foundation;
|
@import shared_preferences_foundation;
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if __has_include(<url_launcher_ios/URLLauncherPlugin.h>)
|
||||||
|
#import <url_launcher_ios/URLLauncherPlugin.h>
|
||||||
|
#else
|
||||||
|
@import url_launcher_ios;
|
||||||
|
#endif
|
||||||
|
|
||||||
@implementation GeneratedPluginRegistrant
|
@implementation GeneratedPluginRegistrant
|
||||||
|
|
||||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
||||||
@@ -49,8 +61,10 @@
|
|||||||
[FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]];
|
[FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]];
|
||||||
[FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]];
|
[FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]];
|
||||||
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
|
[GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]];
|
||||||
|
[FLTGoogleMapsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTGoogleMapsPlugin"]];
|
||||||
[PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]];
|
[PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]];
|
||||||
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
|
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
|
||||||
|
[URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]];
|
||||||
}
|
}
|
||||||
|
|
||||||
@end
|
@end
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>Krow With Us Staff</string>
|
<string>Krow With Us Staff</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
@@ -13,7 +13,7 @@
|
|||||||
<key>CFBundleInfoDictionaryVersion</key>
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
<string>6.0</string>
|
<string>6.0</string>
|
||||||
<key>CFBundleName</key>
|
<key>CFBundleName</key>
|
||||||
<string>Krow With Us Staff</string>
|
<string>Krow With Us Staff</string>
|
||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>APPL</string>
|
<string>APPL</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
@@ -45,5 +45,7 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>UIApplicationSupportsIndirectInputEvents</key>
|
<key>UIApplicationSupportsIndirectInputEvents</key>
|
||||||
<true/>
|
<true/>
|
||||||
|
<key>DART_DEFINES</key>
|
||||||
|
<string>$(DART_DEFINES)</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@@ -5,25 +5,36 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
import 'package:krowwithus_staff/firebase_options.dart';
|
import 'package:krowwithus_staff/firebase_options.dart';
|
||||||
import 'package:staff_authentication/staff_authentication.dart'
|
import 'package:staff_authentication/staff_authentication.dart'
|
||||||
as staff_authentication;
|
as staff_authentication;
|
||||||
import 'package:staff_main/staff_main.dart' as staff_main;
|
import 'package:staff_main/staff_main.dart' as staff_main;
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
|
||||||
|
import 'src/widgets/session_listener.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
await Firebase.initializeApp(
|
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
|
||||||
options: DefaultFirebaseOptions.currentPlatform,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Register global BLoC observer for centralized error logging
|
// Register global BLoC observer for centralized error logging
|
||||||
Bloc.observer = CoreBlocObserver(
|
Bloc.observer = CoreBlocObserver(
|
||||||
logEvents: true,
|
logEvents: true,
|
||||||
logStateChanges: false, // Set to true for verbose debugging
|
logStateChanges: false, // Set to true for verbose debugging
|
||||||
);
|
);
|
||||||
|
|
||||||
runApp(ModularApp(module: AppModule(), child: const AppWidget()));
|
// Initialize session listener for Firebase Auth state changes
|
||||||
|
DataConnectService.instance.initializeAuthListener(
|
||||||
|
allowedRoles: <String>['STAFF', 'BOTH'], // Only allow users with STAFF or BOTH roles
|
||||||
|
);
|
||||||
|
|
||||||
|
runApp(
|
||||||
|
ModularApp(
|
||||||
|
module: AppModule(),
|
||||||
|
child: const SessionListener(child: AppWidget()),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The main application module.
|
/// The main application module.
|
||||||
@@ -34,7 +45,10 @@ class AppModule extends Module {
|
|||||||
@override
|
@override
|
||||||
void routes(RouteManager r) {
|
void routes(RouteManager r) {
|
||||||
// Set the initial route to the authentication module
|
// Set the initial route to the authentication module
|
||||||
r.module(StaffPaths.root, module: staff_authentication.StaffAuthenticationModule());
|
r.module(
|
||||||
|
StaffPaths.root,
|
||||||
|
module: staff_authentication.StaffAuthenticationModule(),
|
||||||
|
);
|
||||||
|
|
||||||
r.module(StaffPaths.main, module: staff_main.StaffMainModule());
|
r.module(StaffPaths.main, module: staff_main.StaffMainModule());
|
||||||
}
|
}
|
||||||
|
|||||||
163
apps/mobile/apps/staff/lib/src/widgets/session_listener.dart
Normal file
163
apps/mobile/apps/staff/lib/src/widgets/session_listener.dart
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
|
|
||||||
|
/// A widget that listens to session state changes and handles global reactions.
|
||||||
|
///
|
||||||
|
/// This widget wraps the entire app and provides centralized session management,
|
||||||
|
/// such as logging out when the session expires or handling session errors.
|
||||||
|
class SessionListener extends StatefulWidget {
|
||||||
|
/// Creates a [SessionListener].
|
||||||
|
const SessionListener({required this.child, super.key});
|
||||||
|
|
||||||
|
/// The child widget to wrap.
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SessionListener> createState() => _SessionListenerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SessionListenerState extends State<SessionListener> {
|
||||||
|
late StreamSubscription<SessionState> _sessionSubscription;
|
||||||
|
bool _sessionExpiredDialogShown = false;
|
||||||
|
bool _isInitialState = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_setupSessionListener();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setupSessionListener() {
|
||||||
|
_sessionSubscription = DataConnectService.instance.onSessionStateChanged
|
||||||
|
.listen((SessionState state) {
|
||||||
|
_handleSessionChange(state);
|
||||||
|
});
|
||||||
|
|
||||||
|
debugPrint('[SessionListener] Initialized session listener');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleSessionChange(SessionState state) {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
switch (state.type) {
|
||||||
|
case SessionStateType.unauthenticated:
|
||||||
|
debugPrint(
|
||||||
|
'[SessionListener] Unauthenticated: Session expired or user logged out',
|
||||||
|
);
|
||||||
|
// On initial state (cold start), just proceed to login without dialog
|
||||||
|
// Only show dialog if user was previously authenticated (session expired)
|
||||||
|
if (_isInitialState) {
|
||||||
|
_isInitialState = false;
|
||||||
|
Modular.to.toGetStartedPage();
|
||||||
|
} else if (!_sessionExpiredDialogShown) {
|
||||||
|
_sessionExpiredDialogShown = true;
|
||||||
|
_showSessionExpiredDialog();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SessionStateType.authenticated:
|
||||||
|
// Session restored or user authenticated
|
||||||
|
_isInitialState = false;
|
||||||
|
_sessionExpiredDialogShown = false;
|
||||||
|
debugPrint('[SessionListener] Authenticated: ${state.userId}');
|
||||||
|
|
||||||
|
// Navigate to the main app
|
||||||
|
Modular.to.toStaffHome();
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SessionStateType.error:
|
||||||
|
// Show error notification with option to retry or logout
|
||||||
|
// Only show if not initial state (avoid showing on cold start)
|
||||||
|
if (!_isInitialState) {
|
||||||
|
debugPrint('[SessionListener] Session error: ${state.errorMessage}');
|
||||||
|
_showSessionErrorDialog(state.errorMessage ?? 'Session error occurred');
|
||||||
|
} else {
|
||||||
|
_isInitialState = false;
|
||||||
|
Modular.to.toGetStartedPage();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case SessionStateType.loading:
|
||||||
|
// Session is loading, optionally show a loading indicator
|
||||||
|
debugPrint('[SessionListener] Session loading...');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows a dialog when the session expires.
|
||||||
|
void _showSessionExpiredDialog() {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Session Expired'),
|
||||||
|
content: const Text(
|
||||||
|
'Your session has expired. Please log in again to continue.',
|
||||||
|
),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_proceedToLogin();
|
||||||
|
},
|
||||||
|
child: const Text('Log In'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows a dialog when a session error occurs, with retry option.
|
||||||
|
void _showSessionErrorDialog(String errorMessage) {
|
||||||
|
showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: false,
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('Session Error'),
|
||||||
|
content: Text(errorMessage),
|
||||||
|
actions: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
// User can retry by dismissing and continuing
|
||||||
|
Modular.to.pop();
|
||||||
|
},
|
||||||
|
child: const Text('Continue'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
_proceedToLogin();
|
||||||
|
},
|
||||||
|
child: const Text('Log Out'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Navigate to login screen and clear navigation stack.
|
||||||
|
void _proceedToLogin() {
|
||||||
|
// Clear service caches on sign-out
|
||||||
|
DataConnectService.instance.handleSignOut();
|
||||||
|
|
||||||
|
// Navigate to authentication
|
||||||
|
Modular.to.toGetStartedPage();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_sessionSubscription.cancel();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) => widget.child;
|
||||||
|
}
|
||||||
@@ -6,6 +6,10 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||||
|
|
||||||
void fl_register_plugins(FlPluginRegistry* registry) {
|
void fl_register_plugins(FlPluginRegistry* registry) {
|
||||||
|
g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin");
|
||||||
|
url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
url_launcher_linux
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import firebase_auth
|
|||||||
import firebase_core
|
import firebase_core
|
||||||
import geolocator_apple
|
import geolocator_apple
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
|
import url_launcher_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
|
FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin"))
|
||||||
@@ -17,4 +18,5 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
|||||||
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin"))
|
||||||
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ dependencies:
|
|||||||
path: ../../packages/features/staff/staff_main
|
path: ../../packages/features/staff/staff_main
|
||||||
krow_core:
|
krow_core:
|
||||||
path: ../../packages/core
|
path: ../../packages/core
|
||||||
|
krow_data_connect:
|
||||||
|
path: ../../packages/data_connect
|
||||||
cupertino_icons: ^1.0.8
|
cupertino_icons: ^1.0.8
|
||||||
flutter_modular: ^6.3.0
|
flutter_modular: ^6.3.0
|
||||||
firebase_core: ^4.4.0
|
firebase_core: ^4.4.0
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||||
#include <geolocator_windows/geolocator_windows.h>
|
#include <geolocator_windows/geolocator_windows.h>
|
||||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
FirebaseAuthPluginCApiRegisterWithRegistrar(
|
FirebaseAuthPluginCApiRegisterWithRegistrar(
|
||||||
@@ -20,4 +21,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
registry->GetRegistrarForPlugin("GeolocatorWindows"));
|
||||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||||
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
firebase_core
|
firebase_core
|
||||||
geolocator_windows
|
geolocator_windows
|
||||||
permission_handler_windows
|
permission_handler_windows
|
||||||
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"GOOGLE_PLACES_API_KEY": "AIzaSyAS9yTf4q51_CNSZ7mbmeS9V3l_LZR80lU"
|
"GOOGLE_MAPS_API_KEY": "AIzaSyAyRS9I4xxoVPAX91RJvWJHszB3ZY3-IC0"
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,6 @@
|
|||||||
class AppConfig {
|
class AppConfig {
|
||||||
AppConfig._();
|
AppConfig._();
|
||||||
|
|
||||||
/// The Google Places API key used for address autocomplete functionality.
|
/// The Google Maps API key.
|
||||||
static const String googlePlacesApiKey = String.fromEnvironment('GOOGLE_PLACES_API_KEY');
|
static const String googleMapsApiKey = String.fromEnvironment('GOOGLE_MAPS_API_KEY');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,20 +21,27 @@ import 'route_paths.dart';
|
|||||||
///
|
///
|
||||||
/// See also:
|
/// See also:
|
||||||
/// * [ClientPaths] for route path constants
|
/// * [ClientPaths] for route path constants
|
||||||
/// * [StaffNavigator] for Staff app navigation
|
/// * [ClientNavigator] for Client app navigation
|
||||||
extension ClientNavigator on IModularNavigator {
|
extension ClientNavigator on IModularNavigator {
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// AUTHENTICATION FLOWS
|
// AUTHENTICATION FLOWS
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
/// Navigate to the root authentication screen.
|
/// Navigate to the root authentication screen.
|
||||||
///
|
///
|
||||||
/// This effectively logs out the user by navigating to root.
|
/// This effectively logs out the user by navigating to root.
|
||||||
/// Used when signing out or session expires.
|
/// Used when signing out or session expires.
|
||||||
void toClientRoot() {
|
void toClientRoot() {
|
||||||
navigate(ClientPaths.root);
|
navigate(ClientPaths.root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Navigates to the get started page.
|
||||||
|
///
|
||||||
|
/// This is the landing page for unauthenticated users, offering login/signup options.
|
||||||
|
void toClientGetStartedPage() {
|
||||||
|
navigate(ClientPaths.getStarted);
|
||||||
|
}
|
||||||
|
|
||||||
/// Navigates to the client sign-in page.
|
/// Navigates to the client sign-in page.
|
||||||
///
|
///
|
||||||
/// This page allows existing clients to log in using email/password
|
/// This page allows existing clients to log in using email/password
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ class ClientPaths {
|
|||||||
/// This serves as the entry point for unauthenticated users.
|
/// This serves as the entry point for unauthenticated users.
|
||||||
static const String root = '/';
|
static const String root = '/';
|
||||||
|
|
||||||
|
/// Get Started page (relative path within auth module).
|
||||||
|
///
|
||||||
|
/// The landing page for unauthenticated users, offering login/signup options.
|
||||||
|
static const String getStarted = '/get-started';
|
||||||
|
|
||||||
/// Sign-in page where existing clients can log into their account.
|
/// Sign-in page where existing clients can log into their account.
|
||||||
///
|
///
|
||||||
/// Supports email/password and social authentication.
|
/// Supports email/password and social authentication.
|
||||||
|
|||||||
@@ -32,10 +32,17 @@ extension StaffNavigator on IModularNavigator {
|
|||||||
///
|
///
|
||||||
/// This effectively logs out the user by navigating to root.
|
/// This effectively logs out the user by navigating to root.
|
||||||
/// Used when signing out or session expires.
|
/// Used when signing out or session expires.
|
||||||
void toGetStarted() {
|
void toInitialPage() {
|
||||||
navigate(StaffPaths.root);
|
navigate(StaffPaths.root);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Navigates to the get started page.
|
||||||
|
///
|
||||||
|
/// This is the landing page for unauthenticated users, offering login/signup options.
|
||||||
|
void toGetStartedPage() {
|
||||||
|
navigate(StaffPaths.getStarted);
|
||||||
|
}
|
||||||
|
|
||||||
/// Navigates to the phone verification page.
|
/// Navigates to the phone verification page.
|
||||||
///
|
///
|
||||||
/// Used for both login and signup flows to verify phone numbers via OTP.
|
/// Used for both login and signup flows to verify phone numbers via OTP.
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ class StaffPaths {
|
|||||||
/// This serves as the entry point for unauthenticated staff members.
|
/// This serves as the entry point for unauthenticated staff members.
|
||||||
static const String root = '/';
|
static const String root = '/';
|
||||||
|
|
||||||
|
/// Get Started page (relative path within auth module).
|
||||||
|
///
|
||||||
|
/// The landing page for unauthenticated users, offering login/signup options.
|
||||||
|
static const String getStarted = '/get-started';
|
||||||
|
|
||||||
/// Phone verification page (relative path within auth module).
|
/// Phone verification page (relative path within auth module).
|
||||||
///
|
///
|
||||||
/// Used for both login and signup flows to verify phone numbers via OTP.
|
/// Used for both login and signup flows to verify phone numbers via OTP.
|
||||||
|
|||||||
@@ -148,9 +148,17 @@
|
|||||||
"edit_mode_active": "Edit Mode Active",
|
"edit_mode_active": "Edit Mode Active",
|
||||||
"drag_instruction": "Drag to reorder, toggle visibility",
|
"drag_instruction": "Drag to reorder, toggle visibility",
|
||||||
"reset": "Reset",
|
"reset": "Reset",
|
||||||
|
"todays_coverage": "TODAY'S COVERAGE",
|
||||||
|
"percent_covered": "$percent% Covered",
|
||||||
"metric_needed": "Needed",
|
"metric_needed": "Needed",
|
||||||
"metric_filled": "Filled",
|
"metric_filled": "Filled",
|
||||||
"metric_open": "Open",
|
"metric_open": "Open",
|
||||||
|
"spending": {
|
||||||
|
"this_week": "This Week",
|
||||||
|
"next_7_days": "Next 7 Days",
|
||||||
|
"shifts_count": "$count shifts",
|
||||||
|
"scheduled_count": "$count scheduled"
|
||||||
|
},
|
||||||
"view_all": "View all",
|
"view_all": "View all",
|
||||||
"insight_lightbulb": "Save $amount/month",
|
"insight_lightbulb": "Save $amount/month",
|
||||||
"insight_tip": "Book 48hrs ahead for better rates"
|
"insight_tip": "Book 48hrs ahead for better rates"
|
||||||
@@ -237,6 +245,14 @@
|
|||||||
"scan_button": "Scan NFC Tag",
|
"scan_button": "Scan NFC Tag",
|
||||||
"tag_identified": "Tag Identified",
|
"tag_identified": "Tag Identified",
|
||||||
"assign_button": "Assign Tag"
|
"assign_button": "Assign Tag"
|
||||||
|
},
|
||||||
|
"delete_dialog": {
|
||||||
|
"title": "Confirm Hub Deletion",
|
||||||
|
"message": "Are you sure you want to delete \"$hubName\"?",
|
||||||
|
"undo_warning": "This action cannot be undone.",
|
||||||
|
"dependency_warning": "Note that if there are any shifts/orders assigned to this hub we shouldn't be able to delete the hub.",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"delete": "Delete"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"client_create_order": {
|
"client_create_order": {
|
||||||
@@ -337,14 +353,26 @@
|
|||||||
"cancelled": "CANCELLED",
|
"cancelled": "CANCELLED",
|
||||||
"get_direction": "Get direction",
|
"get_direction": "Get direction",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"hrs": "HRS",
|
"hrs": "Hrs",
|
||||||
"workers": "$count workers",
|
"workers": "$count workers",
|
||||||
"clock_in": "CLOCK IN",
|
"clock_in": "CLOCK IN",
|
||||||
"clock_out": "CLOCK OUT",
|
"clock_out": "CLOCK OUT",
|
||||||
"coverage": "Coverage",
|
"coverage": "Coverage",
|
||||||
"workers_label": "$filled/$needed Workers",
|
"workers_label": "$filled/$needed Workers",
|
||||||
"confirmed_workers": "Workers Confirmed",
|
"confirmed_workers": "Workers Confirmed",
|
||||||
"no_workers": "No workers confirmed yet."
|
"no_workers": "No workers confirmed yet.",
|
||||||
|
"today": "Today",
|
||||||
|
"tomorrow": "Tomorrow",
|
||||||
|
"workers_needed": "$count Workers Needed",
|
||||||
|
"all_confirmed": "All Workers Confirmed",
|
||||||
|
"confirmed_workers_title": "CONFIRMED WORKERS",
|
||||||
|
"message_all": "Message All",
|
||||||
|
"show_more_workers": "Show $count more workers",
|
||||||
|
"checked_in": "Checked In",
|
||||||
|
"call_dialog": {
|
||||||
|
"title": "Call",
|
||||||
|
"message": "Do you want to call $phone?"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"client_billing": {
|
"client_billing": {
|
||||||
@@ -498,6 +526,10 @@
|
|||||||
"menu_items": {
|
"menu_items": {
|
||||||
"personal_info": "Personal Info",
|
"personal_info": "Personal Info",
|
||||||
"emergency_contact": "Emergency Contact",
|
"emergency_contact": "Emergency Contact",
|
||||||
|
"emergency_contact_page": {
|
||||||
|
"save_success": "Emergency contacts saved successfully",
|
||||||
|
"save_continue": "Save & Continue"
|
||||||
|
},
|
||||||
"experience": "Experience",
|
"experience": "Experience",
|
||||||
"attire": "Attire",
|
"attire": "Attire",
|
||||||
"documents": "Documents",
|
"documents": "Documents",
|
||||||
@@ -853,6 +885,7 @@
|
|||||||
},
|
},
|
||||||
"staff_certificates": {
|
"staff_certificates": {
|
||||||
"title": "Certificates",
|
"title": "Certificates",
|
||||||
|
"error_loading": "Error loading certificates",
|
||||||
"progress": {
|
"progress": {
|
||||||
"title": "Your Progress",
|
"title": "Your Progress",
|
||||||
"verified_count": "$completed of $total verified",
|
"verified_count": "$completed of $total verified",
|
||||||
@@ -966,6 +999,14 @@
|
|||||||
"est_total": "Est. Total",
|
"est_total": "Est. Total",
|
||||||
"hours_label": "$count hours",
|
"hours_label": "$count hours",
|
||||||
"location": "LOCATION",
|
"location": "LOCATION",
|
||||||
|
"tbd": "TBD",
|
||||||
|
"get_direction": "Get direction",
|
||||||
|
"break_title": "BREAK",
|
||||||
|
"paid": "Paid",
|
||||||
|
"unpaid": "Unpaid",
|
||||||
|
"min": "min",
|
||||||
|
"hourly_rate": "Hourly Rate",
|
||||||
|
"hours": "Hours",
|
||||||
"open_in_maps": "Open in Maps",
|
"open_in_maps": "Open in Maps",
|
||||||
"job_description": "JOB DESCRIPTION",
|
"job_description": "JOB DESCRIPTION",
|
||||||
"cancel_shift": "CANCEL SHIFT",
|
"cancel_shift": "CANCEL SHIFT",
|
||||||
@@ -988,6 +1029,41 @@
|
|||||||
"applying_dialog": {
|
"applying_dialog": {
|
||||||
"title": "Applying"
|
"title": "Applying"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"just_now": "Just now",
|
||||||
|
"assigned": "Assigned $time ago",
|
||||||
|
"accept_shift": "Accept shift",
|
||||||
|
"decline_shift": "Decline shift"
|
||||||
|
},
|
||||||
|
"my_shifts_tab": {
|
||||||
|
"confirm_dialog": {
|
||||||
|
"title": "Accept Shift",
|
||||||
|
"message": "Are you sure you want to accept this shift?",
|
||||||
|
"success": "Shift confirmed!"
|
||||||
|
},
|
||||||
|
"decline_dialog": {
|
||||||
|
"title": "Decline Shift",
|
||||||
|
"message": "Are you sure you want to decline this shift? This action cannot be undone.",
|
||||||
|
"success": "Shift declined."
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"awaiting": "Awaiting Confirmation",
|
||||||
|
"cancelled": "Cancelled Shifts",
|
||||||
|
"confirmed": "Confirmed Shifts"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "No shifts this week",
|
||||||
|
"subtitle": "Try finding new jobs in the Find tab"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"today": "Today",
|
||||||
|
"tomorrow": "Tomorrow"
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"cancelled": "CANCELLED",
|
||||||
|
"compensation": "• 4hr compensation"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"staff_time_card": {
|
"staff_time_card": {
|
||||||
|
|||||||
@@ -148,9 +148,17 @@
|
|||||||
"edit_mode_active": "Modo Edición Activo",
|
"edit_mode_active": "Modo Edición Activo",
|
||||||
"drag_instruction": "Arrastra para reordenar, cambia la visibilidad",
|
"drag_instruction": "Arrastra para reordenar, cambia la visibilidad",
|
||||||
"reset": "Restablecer",
|
"reset": "Restablecer",
|
||||||
|
"todays_coverage": "COBERTURA DE HOY",
|
||||||
|
"percent_covered": "$percent% Cubierto",
|
||||||
"metric_needed": "Necesario",
|
"metric_needed": "Necesario",
|
||||||
"metric_filled": "Lleno",
|
"metric_filled": "Lleno",
|
||||||
"metric_open": "Abierto",
|
"metric_open": "Abierto",
|
||||||
|
"spending": {
|
||||||
|
"this_week": "Esta Semana",
|
||||||
|
"next_7_days": "Próximos 7 Días",
|
||||||
|
"shifts_count": "$count turnos",
|
||||||
|
"scheduled_count": "$count programados"
|
||||||
|
},
|
||||||
"view_all": "Ver todo",
|
"view_all": "Ver todo",
|
||||||
"insight_lightbulb": "Ahorra $amount/mes",
|
"insight_lightbulb": "Ahorra $amount/mes",
|
||||||
"insight_tip": "Reserva con 48h de antelación para mejores tarifas"
|
"insight_tip": "Reserva con 48h de antelación para mejores tarifas"
|
||||||
@@ -237,6 +245,14 @@
|
|||||||
"scan_button": "Escanear Etiqueta NFC",
|
"scan_button": "Escanear Etiqueta NFC",
|
||||||
"tag_identified": "Etiqueta Identificada",
|
"tag_identified": "Etiqueta Identificada",
|
||||||
"assign_button": "Asignar Etiqueta"
|
"assign_button": "Asignar Etiqueta"
|
||||||
|
},
|
||||||
|
"delete_dialog": {
|
||||||
|
"title": "Confirmar eliminación de Hub",
|
||||||
|
"message": "¿Estás seguro de que quieres eliminar \"$hubName\"?",
|
||||||
|
"undo_warning": "Esta acción no se puede deshacer.",
|
||||||
|
"dependency_warning": "Ten en cuenta que si hay turnos/órdenes asignados a este hub no deberíamos poder eliminarlo.",
|
||||||
|
"cancel": "Cancelar",
|
||||||
|
"delete": "Eliminar"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"client_create_order": {
|
"client_create_order": {
|
||||||
@@ -337,14 +353,26 @@
|
|||||||
"cancelled": "CANCELADO",
|
"cancelled": "CANCELADO",
|
||||||
"get_direction": "Obtener dirección",
|
"get_direction": "Obtener dirección",
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
"hrs": "HRS",
|
"hrs": "Hrs",
|
||||||
"workers": "$count trabajadores",
|
"workers": "$count trabajadores",
|
||||||
"clock_in": "ENTRADA",
|
"clock_in": "ENTRADA",
|
||||||
"clock_out": "SALIDA",
|
"clock_out": "SALIDA",
|
||||||
"coverage": "Cobertura",
|
"coverage": "Cobertura",
|
||||||
"workers_label": "$filled/$needed Trabajadores",
|
"workers_label": "$filled/$needed Trabajadores",
|
||||||
"confirmed_workers": "Trabajadores Confirmados",
|
"confirmed_workers": "Trabajadores Confirmados",
|
||||||
"no_workers": "Ningún trabajador confirmado aún."
|
"no_workers": "Ningún trabajador confirmado aún.",
|
||||||
|
"today": "Hoy",
|
||||||
|
"tomorrow": "Mañana",
|
||||||
|
"workers_needed": "$count Trabajadores Necesarios",
|
||||||
|
"all_confirmed": "Todos los trabajadores confirmados",
|
||||||
|
"confirmed_workers_title": "TRABAJADORES CONFIRMADOS",
|
||||||
|
"message_all": "Mensaje a todos",
|
||||||
|
"show_more_workers": "Mostrar $count trabajadores más",
|
||||||
|
"checked_in": "Registrado",
|
||||||
|
"call_dialog": {
|
||||||
|
"title": "Llamar",
|
||||||
|
"message": "¿Quieres llamar a $phone?"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"client_billing": {
|
"client_billing": {
|
||||||
@@ -498,6 +526,10 @@
|
|||||||
"menu_items": {
|
"menu_items": {
|
||||||
"personal_info": "Información Personal",
|
"personal_info": "Información Personal",
|
||||||
"emergency_contact": "Contacto de Emergencia",
|
"emergency_contact": "Contacto de Emergencia",
|
||||||
|
"emergency_contact_page": {
|
||||||
|
"save_success": "Contactos de emergencia guardados con éxito",
|
||||||
|
"save_continue": "Guardar y Continuar"
|
||||||
|
},
|
||||||
"experience": "Experiencia",
|
"experience": "Experiencia",
|
||||||
"attire": "Vestimenta",
|
"attire": "Vestimenta",
|
||||||
"documents": "Documentos",
|
"documents": "Documentos",
|
||||||
@@ -853,6 +885,7 @@
|
|||||||
},
|
},
|
||||||
"staff_certificates": {
|
"staff_certificates": {
|
||||||
"title": "Certificados",
|
"title": "Certificados",
|
||||||
|
"error_loading": "Error al cargar certificados",
|
||||||
"progress": {
|
"progress": {
|
||||||
"title": "Tu Progreso",
|
"title": "Tu Progreso",
|
||||||
"verified_count": "$completed de $total verificados",
|
"verified_count": "$completed de $total verificados",
|
||||||
@@ -966,6 +999,14 @@
|
|||||||
"est_total": "Total est.",
|
"est_total": "Total est.",
|
||||||
"hours_label": "$count horas",
|
"hours_label": "$count horas",
|
||||||
"location": "UBICACIÓN",
|
"location": "UBICACIÓN",
|
||||||
|
"tbd": "TBD",
|
||||||
|
"get_direction": "Obtener dirección",
|
||||||
|
"break_title": "DESCANSO",
|
||||||
|
"paid": "Pagado",
|
||||||
|
"unpaid": "No pagado",
|
||||||
|
"min": "min",
|
||||||
|
"hourly_rate": "Tarifa por hora",
|
||||||
|
"hours": "Horas",
|
||||||
"open_in_maps": "Abrir en Mapas",
|
"open_in_maps": "Abrir en Mapas",
|
||||||
"job_description": "DESCRIPCIÓN DEL TRABAJO",
|
"job_description": "DESCRIPCIÓN DEL TRABAJO",
|
||||||
"cancel_shift": "CANCELAR TURNO",
|
"cancel_shift": "CANCELAR TURNO",
|
||||||
@@ -988,6 +1029,41 @@
|
|||||||
"applying_dialog": {
|
"applying_dialog": {
|
||||||
"title": "Solicitando"
|
"title": "Solicitando"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"just_now": "Recién",
|
||||||
|
"assigned": "Asignado hace $time",
|
||||||
|
"accept_shift": "Aceptar turno",
|
||||||
|
"decline_shift": "Rechazar turno"
|
||||||
|
},
|
||||||
|
"my_shifts_tab": {
|
||||||
|
"confirm_dialog": {
|
||||||
|
"title": "Aceptar Turno",
|
||||||
|
"message": "¿Estás seguro de que quieres aceptar este turno?",
|
||||||
|
"success": "¡Turno confirmado!"
|
||||||
|
},
|
||||||
|
"decline_dialog": {
|
||||||
|
"title": "Rechazar Turno",
|
||||||
|
"message": "¿Estás seguro de que quieres rechazar este turno? Esta acción no se puede deshacer.",
|
||||||
|
"success": "Turno rechazado."
|
||||||
|
},
|
||||||
|
"sections": {
|
||||||
|
"awaiting": "Esperando Confirmación",
|
||||||
|
"cancelled": "Turnos Cancelados",
|
||||||
|
"confirmed": "Turnos Confirmados"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "Sin turnos esta semana",
|
||||||
|
"subtitle": "Intenta buscar nuevos trabajos en la pestaña Buscar"
|
||||||
|
},
|
||||||
|
"date": {
|
||||||
|
"today": "Hoy",
|
||||||
|
"tomorrow": "Mañana"
|
||||||
|
},
|
||||||
|
"card": {
|
||||||
|
"cancelled": "CANCELADO",
|
||||||
|
"compensation": "• Compensación de 4h"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"staff_time_card": {
|
"staff_time_card": {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
/// This package provides mock implementations of domain repository interfaces
|
/// This package provides mock implementations of domain repository interfaces
|
||||||
/// for development and testing purposes.
|
/// for development and testing purposes.
|
||||||
///
|
///
|
||||||
/// TODO: These mocks currently do not implement any specific interfaces.
|
|
||||||
/// They will implement interfaces defined in feature packages once those are created.
|
/// They will implement interfaces defined in feature packages once those are created.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
@@ -13,6 +12,8 @@ export 'src/session/client_session_store.dart';
|
|||||||
|
|
||||||
// Export the generated Data Connect SDK
|
// Export the generated Data Connect SDK
|
||||||
export 'src/dataconnect_generated/generated.dart';
|
export 'src/dataconnect_generated/generated.dart';
|
||||||
|
export 'src/services/data_connect_service.dart';
|
||||||
|
export 'src/services/mixins/session_handler_mixin.dart';
|
||||||
|
|
||||||
export 'src/session/staff_session_store.dart';
|
export 'src/session/staff_session_store.dart';
|
||||||
export 'src/mixins/data_error_handler.dart';
|
export 'src/services/mixins/data_error_handler.dart';
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
|
import 'services/data_connect_service.dart';
|
||||||
|
|
||||||
/// A module that provides Data Connect dependencies.
|
/// A module that provides Data Connect dependencies.
|
||||||
class DataConnectModule extends Module {
|
class DataConnectModule extends Module {
|
||||||
@override
|
@override
|
||||||
void exportedBinds(Injector i) {
|
void exportedBinds(Injector i) {
|
||||||
// No mock bindings anymore.
|
i.addInstance<DataConnectService>(DataConnectService.instance);
|
||||||
// Real repositories are instantiated in their feature modules.
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:firebase_core/firebase_core.dart';
|
import 'package:firebase_core/firebase_core.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
|
||||||
/// Mixin to handle Data Layer errors and map them to Domain Failures.
|
/// Mixin to handle Data Layer errors and map them to Domain Failures.
|
||||||
@@ -19,8 +20,12 @@ mixin DataErrorHandler {
|
|||||||
try {
|
try {
|
||||||
return await action().timeout(timeout);
|
return await action().timeout(timeout);
|
||||||
} on TimeoutException {
|
} on TimeoutException {
|
||||||
|
debugPrint(
|
||||||
|
'DataErrorHandler: Request timed out after ${timeout.inSeconds}s',
|
||||||
|
);
|
||||||
throw ServiceUnavailableException(
|
throw ServiceUnavailableException(
|
||||||
technicalMessage: 'Request timed out after ${timeout.inSeconds}s');
|
technicalMessage: 'Request timed out after ${timeout.inSeconds}s',
|
||||||
|
);
|
||||||
} on SocketException catch (e) {
|
} on SocketException catch (e) {
|
||||||
throw NetworkException(technicalMessage: 'SocketException: ${e.message}');
|
throw NetworkException(technicalMessage: 'SocketException: ${e.message}');
|
||||||
} on FirebaseException catch (e) {
|
} on FirebaseException catch (e) {
|
||||||
@@ -31,16 +36,26 @@ mixin DataErrorHandler {
|
|||||||
msg.contains('offline') ||
|
msg.contains('offline') ||
|
||||||
msg.contains('network') ||
|
msg.contains('network') ||
|
||||||
msg.contains('connection failed')) {
|
msg.contains('connection failed')) {
|
||||||
|
debugPrint(
|
||||||
|
'DataErrorHandler: Firebase network error: ${e.code} - ${e.message}',
|
||||||
|
);
|
||||||
throw NetworkException(
|
throw NetworkException(
|
||||||
technicalMessage: 'Firebase ${e.code}: ${e.message}');
|
technicalMessage: 'Firebase ${e.code}: ${e.message}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (code == 'deadline-exceeded') {
|
if (code == 'deadline-exceeded') {
|
||||||
|
debugPrint(
|
||||||
|
'DataErrorHandler: Firebase timeout error: ${e.code} - ${e.message}',
|
||||||
|
);
|
||||||
throw ServiceUnavailableException(
|
throw ServiceUnavailableException(
|
||||||
technicalMessage: 'Firebase ${e.code}: ${e.message}');
|
technicalMessage: 'Firebase ${e.code}: ${e.message}',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
debugPrint('DataErrorHandler: Firebase error: ${e.code} - ${e.message}');
|
||||||
// Fallback for other Firebase errors
|
// Fallback for other Firebase errors
|
||||||
throw ServerException(
|
throw ServerException(
|
||||||
technicalMessage: 'Firebase ${e.code}: ${e.message}');
|
technicalMessage: 'Firebase ${e.code}: ${e.message}',
|
||||||
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
final String errorStr = e.toString().toLowerCase();
|
final String errorStr = e.toString().toLowerCase();
|
||||||
if (errorStr.contains('socketexception') ||
|
if (errorStr.contains('socketexception') ||
|
||||||
@@ -55,15 +70,16 @@ mixin DataErrorHandler {
|
|||||||
errorStr.contains('grpc error') ||
|
errorStr.contains('grpc error') ||
|
||||||
errorStr.contains('terminated') ||
|
errorStr.contains('terminated') ||
|
||||||
errorStr.contains('connectexception')) {
|
errorStr.contains('connectexception')) {
|
||||||
|
debugPrint('DataErrorHandler: Network-related error: $e');
|
||||||
throw NetworkException(technicalMessage: e.toString());
|
throw NetworkException(technicalMessage: e.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
// If it's already an AppException, rethrow it
|
// If it's already an AppException, rethrow it
|
||||||
if (e is AppException) rethrow;
|
if (e is AppException) rethrow;
|
||||||
|
|
||||||
// Debugging: Log unexpected errors
|
// Debugging: Log unexpected errors
|
||||||
print('DataErrorHandler: Unhandled exception caught: $e');
|
debugPrint('DataErrorHandler: Unhandled exception caught: $e');
|
||||||
|
|
||||||
throw UnknownException(technicalMessage: e.toString());
|
throw UnknownException(technicalMessage: e.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart' as firebase_auth;
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
|
||||||
|
/// Enum representing the current session state.
|
||||||
|
enum SessionStateType { loading, authenticated, unauthenticated, error }
|
||||||
|
|
||||||
|
/// Data class for session state.
|
||||||
|
class SessionState {
|
||||||
|
/// Creates a [SessionState].
|
||||||
|
SessionState({required this.type, this.userId, this.errorMessage});
|
||||||
|
|
||||||
|
/// Creates a loading state.
|
||||||
|
factory SessionState.loading() =>
|
||||||
|
SessionState(type: SessionStateType.loading);
|
||||||
|
|
||||||
|
/// Creates an authenticated state.
|
||||||
|
factory SessionState.authenticated({required String userId}) =>
|
||||||
|
SessionState(type: SessionStateType.authenticated, userId: userId);
|
||||||
|
|
||||||
|
/// Creates an unauthenticated state.
|
||||||
|
factory SessionState.unauthenticated() =>
|
||||||
|
SessionState(type: SessionStateType.unauthenticated);
|
||||||
|
|
||||||
|
/// Creates an error state.
|
||||||
|
factory SessionState.error(String message) =>
|
||||||
|
SessionState(type: SessionStateType.error, errorMessage: message);
|
||||||
|
|
||||||
|
/// The type of session state.
|
||||||
|
final SessionStateType type;
|
||||||
|
|
||||||
|
/// The current user ID (if authenticated).
|
||||||
|
final String? userId;
|
||||||
|
|
||||||
|
/// Error message (if error occurred).
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() =>
|
||||||
|
'SessionState(type: $type, userId: $userId, error: $errorMessage)';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mixin for handling Firebase Auth session management, token refresh, and state emissions.
|
||||||
|
mixin SessionHandlerMixin {
|
||||||
|
/// Stream controller for session state changes.
|
||||||
|
final StreamController<SessionState> _sessionStateController =
|
||||||
|
StreamController<SessionState>.broadcast();
|
||||||
|
|
||||||
|
/// Last emitted session state (for late subscribers).
|
||||||
|
SessionState? _lastSessionState;
|
||||||
|
|
||||||
|
/// Public stream for listening to session state changes.
|
||||||
|
/// Late subscribers will immediately receive the last emitted state.
|
||||||
|
Stream<SessionState> get onSessionStateChanged {
|
||||||
|
// Create a custom stream that emits the last state before forwarding new events
|
||||||
|
return _createStreamWithLastState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a stream that emits the last state before subscribing to new events.
|
||||||
|
Stream<SessionState> _createStreamWithLastState() async* {
|
||||||
|
// If we have a last state, emit it immediately to late subscribers
|
||||||
|
if (_lastSessionState != null) {
|
||||||
|
yield _lastSessionState!;
|
||||||
|
}
|
||||||
|
// Then forward all subsequent events
|
||||||
|
yield* _sessionStateController.stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Last token refresh timestamp to avoid excessive checks.
|
||||||
|
DateTime? _lastTokenRefreshTime;
|
||||||
|
|
||||||
|
/// Subscription to auth state changes.
|
||||||
|
StreamSubscription<firebase_auth.User?>? _authStateSubscription;
|
||||||
|
|
||||||
|
/// Minimum interval between token refresh checks.
|
||||||
|
static const Duration _minRefreshCheckInterval = Duration(seconds: 2);
|
||||||
|
|
||||||
|
/// Time before token expiry to trigger a refresh.
|
||||||
|
static const Duration _refreshThreshold = Duration(minutes: 5);
|
||||||
|
|
||||||
|
/// Firebase Auth instance (to be provided by implementing class).
|
||||||
|
firebase_auth.FirebaseAuth get auth;
|
||||||
|
|
||||||
|
/// List of allowed roles for this app (to be set during initialization).
|
||||||
|
List<String> _allowedRoles = <String>[];
|
||||||
|
|
||||||
|
/// Initialize the auth state listener (call once on app startup).
|
||||||
|
void initializeAuthListener({List<String> allowedRoles = const <String>[]}) {
|
||||||
|
_allowedRoles = allowedRoles;
|
||||||
|
|
||||||
|
// Cancel any existing subscription first
|
||||||
|
_authStateSubscription?.cancel();
|
||||||
|
|
||||||
|
// Listen to Firebase auth state changes
|
||||||
|
_authStateSubscription = auth.authStateChanges().listen(
|
||||||
|
(firebase_auth.User? user) async {
|
||||||
|
if (user == null) {
|
||||||
|
_handleSignOut();
|
||||||
|
} else {
|
||||||
|
await _handleSignIn(user);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (Object error) {
|
||||||
|
_emitSessionState(SessionState.error(error.toString()));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates if user has one of the allowed roles.
|
||||||
|
/// Returns true if user role is in allowed roles, false otherwise.
|
||||||
|
Future<bool> validateUserRole(
|
||||||
|
String userId,
|
||||||
|
List<String> allowedRoles,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final String? userRole = await fetchUserRole(userId);
|
||||||
|
return userRole != null && allowedRoles.contains(userRole);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to validate user role: $e');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches user role from Data Connect.
|
||||||
|
/// To be implemented by concrete class.
|
||||||
|
Future<String?> fetchUserRole(String userId);
|
||||||
|
|
||||||
|
/// Ensures the Firebase auth token is valid and refreshes if needed.
|
||||||
|
/// Retries up to 3 times with exponential backoff before emitting error.
|
||||||
|
Future<void> ensureSessionValid() async {
|
||||||
|
final firebase_auth.User? user = auth.currentUser;
|
||||||
|
|
||||||
|
// No user = not authenticated, skip check
|
||||||
|
if (user == null) return;
|
||||||
|
|
||||||
|
// Optimization: Skip if we just checked within the last 2 seconds
|
||||||
|
final DateTime now = DateTime.now();
|
||||||
|
if (_lastTokenRefreshTime != null) {
|
||||||
|
final Duration timeSinceLastCheck = now.difference(
|
||||||
|
_lastTokenRefreshTime!,
|
||||||
|
);
|
||||||
|
if (timeSinceLastCheck < _minRefreshCheckInterval) {
|
||||||
|
return; // Skip redundant check
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const int maxRetries = 3;
|
||||||
|
int retryCount = 0;
|
||||||
|
|
||||||
|
while (retryCount < maxRetries) {
|
||||||
|
try {
|
||||||
|
// Get token result (doesn't fetch from network unless needed)
|
||||||
|
final firebase_auth.IdTokenResult idToken = await user
|
||||||
|
.getIdTokenResult();
|
||||||
|
|
||||||
|
// Extract expiration time
|
||||||
|
final DateTime? expiryTime = idToken.expirationTime;
|
||||||
|
|
||||||
|
if (expiryTime == null) {
|
||||||
|
return; // Token info unavailable, proceed anyway
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate time until expiry
|
||||||
|
final Duration timeUntilExpiry = expiryTime.difference(now);
|
||||||
|
|
||||||
|
// If token expires within 5 minutes, refresh it
|
||||||
|
if (timeUntilExpiry <= _refreshThreshold) {
|
||||||
|
await user.getIdTokenResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update last refresh check timestamp
|
||||||
|
_lastTokenRefreshTime = now;
|
||||||
|
return; // Success, exit retry loop
|
||||||
|
} catch (e) {
|
||||||
|
retryCount++;
|
||||||
|
debugPrint(
|
||||||
|
'Token validation error (attempt $retryCount/$maxRetries): $e',
|
||||||
|
);
|
||||||
|
|
||||||
|
// If we've exhausted retries, emit error
|
||||||
|
if (retryCount >= maxRetries) {
|
||||||
|
_emitSessionState(
|
||||||
|
SessionState.error(
|
||||||
|
'Token validation failed after $maxRetries attempts: $e',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exponential backoff: 1s, 2s, 4s
|
||||||
|
final Duration backoffDuration = Duration(
|
||||||
|
seconds: 1 << (retryCount - 1), // 2^(retryCount-1)
|
||||||
|
);
|
||||||
|
debugPrint(
|
||||||
|
'Retrying token validation in ${backoffDuration.inSeconds}s',
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(backoffDuration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle user sign-in event.
|
||||||
|
Future<void> _handleSignIn(firebase_auth.User user) async {
|
||||||
|
try {
|
||||||
|
_emitSessionState(SessionState.loading());
|
||||||
|
|
||||||
|
// Validate role if allowed roles are specified
|
||||||
|
if (_allowedRoles.isNotEmpty) {
|
||||||
|
final bool isAuthorized = await validateUserRole(
|
||||||
|
user.uid,
|
||||||
|
_allowedRoles,
|
||||||
|
);
|
||||||
|
if (!isAuthorized) {
|
||||||
|
await auth.signOut();
|
||||||
|
_emitSessionState(SessionState.unauthenticated());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get fresh token to validate session
|
||||||
|
final firebase_auth.IdTokenResult idToken = await user.getIdTokenResult();
|
||||||
|
if (idToken.expirationTime != null &&
|
||||||
|
DateTime.now().difference(idToken.expirationTime!) <
|
||||||
|
const Duration(minutes: 5)) {
|
||||||
|
// Token is expiring soon, refresh it
|
||||||
|
await user.getIdTokenResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit authenticated state
|
||||||
|
_emitSessionState(SessionState.authenticated(userId: user.uid));
|
||||||
|
} catch (e) {
|
||||||
|
_emitSessionState(SessionState.error(e.toString()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle user sign-out event.
|
||||||
|
void _handleSignOut() {
|
||||||
|
_emitSessionState(SessionState.unauthenticated());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emit session state update.
|
||||||
|
void _emitSessionState(SessionState state) {
|
||||||
|
_lastSessionState = state;
|
||||||
|
if (!_sessionStateController.isClosed) {
|
||||||
|
_sessionStateController.add(state);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispose session handler resources.
|
||||||
|
Future<void> disposeSessionHandler() async {
|
||||||
|
await _authStateSubscription?.cancel();
|
||||||
|
await _sessionStateController.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
|
||||||
|
|
||||||
class ClientBusinessSession {
|
class ClientBusinessSession {
|
||||||
final String id;
|
final String id;
|
||||||
final String businessName;
|
final String businessName;
|
||||||
@@ -19,15 +17,9 @@ class ClientBusinessSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class ClientSession {
|
class ClientSession {
|
||||||
final domain.User user;
|
|
||||||
final String? userPhotoUrl;
|
|
||||||
final ClientBusinessSession? business;
|
final ClientBusinessSession? business;
|
||||||
|
|
||||||
const ClientSession({
|
const ClientSession({required this.business});
|
||||||
required this.user,
|
|
||||||
required this.userPhotoUrl,
|
|
||||||
required this.business,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class ClientSessionStore {
|
class ClientSessionStore {
|
||||||
|
|||||||
@@ -1,18 +1,15 @@
|
|||||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||||
|
|
||||||
class StaffSession {
|
class StaffSession {
|
||||||
|
const StaffSession({required this.user, this.staff, this.ownerId});
|
||||||
|
|
||||||
final domain.User user;
|
final domain.User user;
|
||||||
final domain.Staff? staff;
|
final domain.Staff? staff;
|
||||||
final String? ownerId;
|
final String? ownerId;
|
||||||
|
|
||||||
const StaffSession({
|
|
||||||
required this.user,
|
|
||||||
this.staff,
|
|
||||||
this.ownerId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class StaffSessionStore {
|
class StaffSessionStore {
|
||||||
|
StaffSessionStore._();
|
||||||
StaffSession? _session;
|
StaffSession? _session;
|
||||||
|
|
||||||
StaffSession? get session => _session;
|
StaffSession? get session => _session;
|
||||||
@@ -26,6 +23,4 @@ class StaffSessionStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static final StaffSessionStore instance = StaffSessionStore._();
|
static final StaffSessionStore instance = StaffSessionStore._();
|
||||||
|
|
||||||
StaffSessionStore._();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,3 +15,6 @@ dependencies:
|
|||||||
path: ../domain
|
path: ../domain
|
||||||
flutter_modular: ^6.3.0
|
flutter_modular: ^6.3.0
|
||||||
firebase_data_connect: ^0.2.2+2
|
firebase_data_connect: ^0.2.2+2
|
||||||
|
firebase_core: ^4.4.0
|
||||||
|
firebase_auth: ^6.1.4
|
||||||
|
krow_core: ^0.0.1
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class UiTheme {
|
|||||||
dividerTheme: const DividerThemeData(
|
dividerTheme: const DividerThemeData(
|
||||||
color: UiColors.separatorPrimary,
|
color: UiColors.separatorPrimary,
|
||||||
space: 1,
|
space: 1,
|
||||||
thickness: 1,
|
thickness: 0.5,
|
||||||
),
|
),
|
||||||
|
|
||||||
// Card Theme
|
// Card Theme
|
||||||
|
|||||||
@@ -173,6 +173,14 @@ class UiTypography {
|
|||||||
color: UiColors.textPrimary,
|
color: UiColors.textPrimary,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Headline 1 Bold - Font: Instrument Sans, Size: 26, Height: 1.5 (#121826)
|
||||||
|
static final TextStyle headline1b = _primaryBase.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
fontSize: 26,
|
||||||
|
height: 1.5,
|
||||||
|
color: UiColors.textPrimary,
|
||||||
|
);
|
||||||
|
|
||||||
/// Headline 2 Medium - Font: Instrument Sans, Size: 20, Height: 1.5 (#121826)
|
/// Headline 2 Medium - Font: Instrument Sans, Size: 20, Height: 1.5 (#121826)
|
||||||
static final TextStyle headline2m = _primaryBase.copyWith(
|
static final TextStyle headline2m = _primaryBase.copyWith(
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ export 'src/entities/events/work_session.dart';
|
|||||||
// Shifts
|
// Shifts
|
||||||
export 'src/entities/shifts/shift.dart';
|
export 'src/entities/shifts/shift.dart';
|
||||||
export 'src/adapters/shifts/shift_adapter.dart';
|
export 'src/adapters/shifts/shift_adapter.dart';
|
||||||
|
export 'src/entities/shifts/break/break.dart';
|
||||||
|
export 'src/adapters/shifts/break/break_adapter.dart';
|
||||||
|
|
||||||
// Orders & Requests
|
// Orders & Requests
|
||||||
export 'src/entities/orders/order_type.dart';
|
export 'src/entities/orders/order_type.dart';
|
||||||
@@ -51,6 +53,10 @@ export 'src/entities/financial/invoice_item.dart';
|
|||||||
export 'src/entities/financial/invoice_decline.dart';
|
export 'src/entities/financial/invoice_decline.dart';
|
||||||
export 'src/entities/financial/staff_payment.dart';
|
export 'src/entities/financial/staff_payment.dart';
|
||||||
export 'src/entities/financial/payment_summary.dart';
|
export 'src/entities/financial/payment_summary.dart';
|
||||||
|
export 'src/entities/financial/bank_account/bank_account.dart';
|
||||||
|
export 'src/entities/financial/bank_account/business_bank_account.dart';
|
||||||
|
export 'src/entities/financial/bank_account/staff_bank_account.dart';
|
||||||
|
export 'src/adapters/financial/bank_account/bank_account_adapter.dart';
|
||||||
|
|
||||||
// Profile
|
// Profile
|
||||||
export 'src/entities/profile/staff_document.dart';
|
export 'src/entities/profile/staff_document.dart';
|
||||||
@@ -66,7 +72,6 @@ export 'src/entities/ratings/business_staff_preference.dart';
|
|||||||
|
|
||||||
// Staff Profile
|
// Staff Profile
|
||||||
export 'src/entities/profile/emergency_contact.dart';
|
export 'src/entities/profile/emergency_contact.dart';
|
||||||
export 'src/entities/profile/bank_account.dart';
|
|
||||||
export 'src/entities/profile/accessibility.dart';
|
export 'src/entities/profile/accessibility.dart';
|
||||||
export 'src/entities/profile/schedule.dart';
|
export 'src/entities/profile/schedule.dart';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import '../../../entities/financial/bank_account/business_bank_account.dart';
|
||||||
|
|
||||||
|
/// Adapter for [BusinessBankAccount] to map data layer values to domain entity.
|
||||||
|
class BusinessBankAccountAdapter {
|
||||||
|
/// Maps primitive values to [BusinessBankAccount].
|
||||||
|
static BusinessBankAccount fromPrimitives({
|
||||||
|
required String id,
|
||||||
|
required String bank,
|
||||||
|
required String last4,
|
||||||
|
required bool isPrimary,
|
||||||
|
DateTime? expiryTime,
|
||||||
|
}) {
|
||||||
|
return BusinessBankAccount(
|
||||||
|
id: id,
|
||||||
|
bankName: bank,
|
||||||
|
last4: last4,
|
||||||
|
isPrimary: isPrimary,
|
||||||
|
expiryTime: expiryTime,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import '../../entities/profile/bank_account.dart';
|
import '../../entities/financial/bank_account/staff_bank_account.dart';
|
||||||
|
|
||||||
/// Adapter for [BankAccount] to map data layer values to domain entity.
|
/// Adapter for [StaffBankAccount] to map data layer values to domain entity.
|
||||||
class BankAccountAdapter {
|
class BankAccountAdapter {
|
||||||
/// Maps primitive values to [BankAccount].
|
/// Maps primitive values to [StaffBankAccount].
|
||||||
static BankAccount fromPrimitives({
|
static StaffBankAccount fromPrimitives({
|
||||||
required String id,
|
required String id,
|
||||||
required String userId,
|
required String userId,
|
||||||
required String bankName,
|
required String bankName,
|
||||||
@@ -13,7 +13,7 @@ class BankAccountAdapter {
|
|||||||
String? sortCode,
|
String? sortCode,
|
||||||
bool? isPrimary,
|
bool? isPrimary,
|
||||||
}) {
|
}) {
|
||||||
return BankAccount(
|
return StaffBankAccount(
|
||||||
id: id,
|
id: id,
|
||||||
userId: userId,
|
userId: userId,
|
||||||
bankName: bankName,
|
bankName: bankName,
|
||||||
@@ -26,25 +26,25 @@ class BankAccountAdapter {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static BankAccountType _stringToType(String? value) {
|
static StaffBankAccountType _stringToType(String? value) {
|
||||||
if (value == null) return BankAccountType.checking;
|
if (value == null) return StaffBankAccountType.checking;
|
||||||
try {
|
try {
|
||||||
// Assuming backend enum names match or are uppercase
|
// Assuming backend enum names match or are uppercase
|
||||||
return BankAccountType.values.firstWhere(
|
return StaffBankAccountType.values.firstWhere(
|
||||||
(e) => e.name.toLowerCase() == value.toLowerCase(),
|
(StaffBankAccountType e) => e.name.toLowerCase() == value.toLowerCase(),
|
||||||
orElse: () => BankAccountType.other,
|
orElse: () => StaffBankAccountType.other,
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return BankAccountType.other;
|
return StaffBankAccountType.other;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Converts domain type to string for backend.
|
/// Converts domain type to string for backend.
|
||||||
static String typeToString(BankAccountType type) {
|
static String typeToString(StaffBankAccountType type) {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case BankAccountType.checking:
|
case StaffBankAccountType.checking:
|
||||||
return 'CHECKING';
|
return 'CHECKING';
|
||||||
case BankAccountType.savings:
|
case StaffBankAccountType.savings:
|
||||||
return 'SAVINGS';
|
return 'SAVINGS';
|
||||||
default:
|
default:
|
||||||
return 'CHECKING';
|
return 'CHECKING';
|
||||||
|
|||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import 'package:equatable/equatable.dart';
|
||||||
|
|
||||||
|
/// Abstract base class for all types of bank accounts.
|
||||||
|
abstract class BankAccount extends Equatable {
|
||||||
|
/// Creates a [BankAccount].
|
||||||
|
const BankAccount({
|
||||||
|
required this.id,
|
||||||
|
required this.bankName,
|
||||||
|
required this.isPrimary,
|
||||||
|
this.last4,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Unique identifier.
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// Name of the bank or provider.
|
||||||
|
final String bankName;
|
||||||
|
|
||||||
|
/// Whether this is the primary payment method.
|
||||||
|
final bool isPrimary;
|
||||||
|
|
||||||
|
/// Last 4 digits of the account/card.
|
||||||
|
final String? last4;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => <Object?>[id, bankName, isPrimary, last4];
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import 'bank_account.dart';
|
||||||
|
|
||||||
|
/// Domain model representing a business bank account or payment method.
|
||||||
|
class BusinessBankAccount extends BankAccount {
|
||||||
|
/// Creates a [BusinessBankAccount].
|
||||||
|
const BusinessBankAccount({
|
||||||
|
required super.id,
|
||||||
|
required super.bankName,
|
||||||
|
required String last4,
|
||||||
|
required super.isPrimary,
|
||||||
|
this.expiryTime,
|
||||||
|
}) : super(last4: last4);
|
||||||
|
|
||||||
|
/// Expiration date if applicable.
|
||||||
|
final DateTime? expiryTime;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props => <Object?>[
|
||||||
|
...super.props,
|
||||||
|
expiryTime,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Getter for non-nullable last4 in Business context.
|
||||||
|
@override
|
||||||
|
String get last4 => super.last4!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import 'bank_account.dart';
|
||||||
|
|
||||||
|
/// Type of staff bank account.
|
||||||
|
enum StaffBankAccountType {
|
||||||
|
/// Checking account.
|
||||||
|
checking,
|
||||||
|
|
||||||
|
/// Savings account.
|
||||||
|
savings,
|
||||||
|
|
||||||
|
/// Other type.
|
||||||
|
other,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Domain entity representing a staff's bank account.
|
||||||
|
class StaffBankAccount extends BankAccount {
|
||||||
|
/// Creates a [StaffBankAccount].
|
||||||
|
const StaffBankAccount({
|
||||||
|
required super.id,
|
||||||
|
required this.userId,
|
||||||
|
required super.bankName,
|
||||||
|
required this.accountNumber,
|
||||||
|
required this.accountName,
|
||||||
|
required super.isPrimary,
|
||||||
|
super.last4,
|
||||||
|
this.sortCode,
|
||||||
|
this.type = StaffBankAccountType.checking,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// User identifier.
|
||||||
|
final String userId;
|
||||||
|
|
||||||
|
/// Full account number.
|
||||||
|
final String accountNumber;
|
||||||
|
|
||||||
|
/// Name of the account holder.
|
||||||
|
final String accountName;
|
||||||
|
|
||||||
|
/// Sort code (optional).
|
||||||
|
final String? sortCode;
|
||||||
|
|
||||||
|
/// Account type.
|
||||||
|
final StaffBankAccountType type;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get props =>
|
||||||
|
<Object?>[...super.props, userId, accountNumber, accountName, sortCode, type];
|
||||||
|
}
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
|
||||||
|
|
||||||
/// Account type (Checking, Savings, etc).
|
|
||||||
enum BankAccountType {
|
|
||||||
checking,
|
|
||||||
savings,
|
|
||||||
other,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Represents bank account details for payroll.
|
|
||||||
class BankAccount extends Equatable {
|
|
||||||
|
|
||||||
const BankAccount({
|
|
||||||
required this.id,
|
|
||||||
required this.userId,
|
|
||||||
required this.bankName,
|
|
||||||
required this.accountNumber,
|
|
||||||
required this.accountName,
|
|
||||||
this.sortCode,
|
|
||||||
this.type = BankAccountType.checking,
|
|
||||||
this.isPrimary = false,
|
|
||||||
this.last4,
|
|
||||||
});
|
|
||||||
/// Unique identifier.
|
|
||||||
final String id;
|
|
||||||
|
|
||||||
/// The [User] owning the account.
|
|
||||||
final String userId;
|
|
||||||
|
|
||||||
/// Name of the bank.
|
|
||||||
final String bankName;
|
|
||||||
|
|
||||||
/// Account number.
|
|
||||||
final String accountNumber;
|
|
||||||
|
|
||||||
/// Name on the account.
|
|
||||||
final String accountName;
|
|
||||||
|
|
||||||
/// Sort code (if applicable).
|
|
||||||
final String? sortCode;
|
|
||||||
|
|
||||||
/// Type of account.
|
|
||||||
final BankAccountType type;
|
|
||||||
|
|
||||||
/// Whether this is the primary account.
|
|
||||||
final bool isPrimary;
|
|
||||||
|
|
||||||
/// Last 4 digits.
|
|
||||||
final String? last4;
|
|
||||||
|
|
||||||
@override
|
|
||||||
List<Object?> get props => <Object?>[id, userId, bankName, accountNumber, accountName, sortCode, type, isPrimary, last4];
|
|
||||||
}
|
|
||||||
@@ -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];
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:krow_domain/src/entities/shifts/break/break.dart';
|
||||||
|
|
||||||
class Shift extends Equatable {
|
class Shift extends Equatable {
|
||||||
final String id;
|
final String id;
|
||||||
@@ -29,6 +30,7 @@ class Shift extends Equatable {
|
|||||||
final String? roleId;
|
final String? roleId;
|
||||||
final bool? hasApplied;
|
final bool? hasApplied;
|
||||||
final double? totalValue;
|
final double? totalValue;
|
||||||
|
final Break? breakInfo;
|
||||||
|
|
||||||
const Shift({
|
const Shift({
|
||||||
required this.id,
|
required this.id,
|
||||||
@@ -59,48 +61,49 @@ class Shift extends Equatable {
|
|||||||
this.roleId,
|
this.roleId,
|
||||||
this.hasApplied,
|
this.hasApplied,
|
||||||
this.totalValue,
|
this.totalValue,
|
||||||
|
this.breakInfo,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [
|
List<Object?> get props => <Object?>[
|
||||||
id,
|
id,
|
||||||
title,
|
title,
|
||||||
clientName,
|
clientName,
|
||||||
logoUrl,
|
logoUrl,
|
||||||
hourlyRate,
|
hourlyRate,
|
||||||
location,
|
location,
|
||||||
locationAddress,
|
locationAddress,
|
||||||
date,
|
date,
|
||||||
startTime,
|
startTime,
|
||||||
endTime,
|
endTime,
|
||||||
createdDate,
|
createdDate,
|
||||||
tipsAvailable,
|
tipsAvailable,
|
||||||
travelTime,
|
travelTime,
|
||||||
mealProvided,
|
mealProvided,
|
||||||
parkingAvailable,
|
parkingAvailable,
|
||||||
gasCompensation,
|
gasCompensation,
|
||||||
description,
|
description,
|
||||||
instructions,
|
instructions,
|
||||||
managers,
|
managers,
|
||||||
latitude,
|
latitude,
|
||||||
longitude,
|
longitude,
|
||||||
status,
|
status,
|
||||||
durationDays,
|
durationDays,
|
||||||
requiredSlots,
|
requiredSlots,
|
||||||
filledSlots,
|
filledSlots,
|
||||||
roleId,
|
roleId,
|
||||||
hasApplied,
|
hasApplied,
|
||||||
totalValue,
|
totalValue,
|
||||||
];
|
breakInfo,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
class ShiftManager extends Equatable {
|
class ShiftManager extends Equatable {
|
||||||
|
const ShiftManager({required this.name, required this.phone, this.avatar});
|
||||||
|
|
||||||
final String name;
|
final String name;
|
||||||
final String phone;
|
final String phone;
|
||||||
final String? avatar;
|
final String? avatar;
|
||||||
|
|
||||||
const ShiftManager({required this.name, required this.phone, this.avatar});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object?> get props => [name, phone, avatar];
|
List<Object?> get props => <Object?>[name, phone, avatar];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
library;
|
library;
|
||||||
|
|
||||||
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
@@ -11,6 +10,7 @@ import 'src/domain/usecases/sign_in_with_social_use_case.dart';
|
|||||||
import 'src/domain/usecases/sign_out_use_case.dart';
|
import 'src/domain/usecases/sign_out_use_case.dart';
|
||||||
import 'src/domain/usecases/sign_up_with_email_use_case.dart';
|
import 'src/domain/usecases/sign_up_with_email_use_case.dart';
|
||||||
import 'src/presentation/blocs/client_auth_bloc.dart';
|
import 'src/presentation/blocs/client_auth_bloc.dart';
|
||||||
|
import 'src/presentation/pages/client_intro_page.dart';
|
||||||
import 'src/presentation/pages/client_get_started_page.dart';
|
import 'src/presentation/pages/client_get_started_page.dart';
|
||||||
import 'src/presentation/pages/client_sign_in_page.dart';
|
import 'src/presentation/pages/client_sign_in_page.dart';
|
||||||
import 'src/presentation/pages/client_sign_up_page.dart';
|
import 'src/presentation/pages/client_sign_up_page.dart';
|
||||||
@@ -28,12 +28,7 @@ class ClientAuthenticationModule extends Module {
|
|||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
// Repositories
|
// Repositories
|
||||||
i.addLazySingleton<AuthRepositoryInterface>(
|
i.addLazySingleton<AuthRepositoryInterface>(AuthRepositoryImpl.new);
|
||||||
() => AuthRepositoryImpl(
|
|
||||||
firebaseAuth: firebase.FirebaseAuth.instance,
|
|
||||||
dataConnect: ExampleConnector.instance,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// UseCases
|
// UseCases
|
||||||
i.addLazySingleton(
|
i.addLazySingleton(
|
||||||
@@ -60,7 +55,8 @@ class ClientAuthenticationModule extends Module {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void routes(RouteManager r) {
|
void routes(RouteManager r) {
|
||||||
r.child(ClientPaths.root, child: (_) => const ClientGetStartedPage());
|
r.child(ClientPaths.root, child: (_) => const ClientIntroPage());
|
||||||
|
r.child(ClientPaths.getStarted, child: (_) => const ClientGetStartedPage());
|
||||||
r.child(ClientPaths.signIn, child: (_) => const ClientSignInPage());
|
r.child(ClientPaths.signIn, child: (_) => const ClientSignInPage());
|
||||||
r.child(ClientPaths.signUp, child: (_) => const ClientSignUpPage());
|
r.child(ClientPaths.signUp, child: (_) => const ClientSignUpPage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import 'package:krow_domain/krow_domain.dart'
|
|||||||
AccountExistsException,
|
AccountExistsException,
|
||||||
UserNotFoundException,
|
UserNotFoundException,
|
||||||
UnauthorizedAppException,
|
UnauthorizedAppException,
|
||||||
UnauthorizedAppException,
|
|
||||||
PasswordMismatchException,
|
PasswordMismatchException,
|
||||||
NetworkException;
|
NetworkException;
|
||||||
import 'package:krow_domain/krow_domain.dart' as domain;
|
import 'package:krow_domain/krow_domain.dart' as domain;
|
||||||
@@ -23,18 +22,12 @@ import '../../domain/repositories/auth_repository_interface.dart';
|
|||||||
///
|
///
|
||||||
/// This implementation integrates with Firebase Authentication for user
|
/// This implementation integrates with Firebase Authentication for user
|
||||||
/// identity management and Krow's Data Connect SDK for storing user profile data.
|
/// identity management and Krow's Data Connect SDK for storing user profile data.
|
||||||
class AuthRepositoryImpl
|
class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||||
with dc.DataErrorHandler
|
|
||||||
implements AuthRepositoryInterface {
|
|
||||||
|
|
||||||
/// Creates an [AuthRepositoryImpl] with the real dependencies.
|
/// Creates an [AuthRepositoryImpl] with the real dependencies.
|
||||||
AuthRepositoryImpl({
|
AuthRepositoryImpl({dc.DataConnectService? service})
|
||||||
required firebase.FirebaseAuth firebaseAuth,
|
: _service = service ?? dc.DataConnectService.instance;
|
||||||
required dc.ExampleConnector dataConnect,
|
|
||||||
}) : _firebaseAuth = firebaseAuth,
|
final dc.DataConnectService _service;
|
||||||
_dataConnect = dataConnect;
|
|
||||||
final firebase.FirebaseAuth _firebaseAuth;
|
|
||||||
final dc.ExampleConnector _dataConnect;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<domain.User> signInWithEmail({
|
Future<domain.User> signInWithEmail({
|
||||||
@@ -42,10 +35,8 @@ class AuthRepositoryImpl
|
|||||||
required String password,
|
required String password,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
final firebase.UserCredential credential = await _firebaseAuth.signInWithEmailAndPassword(
|
final firebase.UserCredential credential = await _service.auth
|
||||||
email: email,
|
.signInWithEmailAndPassword(email: email, password: password);
|
||||||
password: password,
|
|
||||||
);
|
|
||||||
|
|
||||||
final firebase.User? firebaseUser = credential.user;
|
final firebase.User? firebaseUser = credential.user;
|
||||||
if (firebaseUser == null) {
|
if (firebaseUser == null) {
|
||||||
@@ -59,16 +50,13 @@ class AuthRepositoryImpl
|
|||||||
fallbackEmail: firebaseUser.email ?? email,
|
fallbackEmail: firebaseUser.email ?? email,
|
||||||
requireBusinessRole: true,
|
requireBusinessRole: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
} on firebase.FirebaseAuthException catch (e) {
|
} on firebase.FirebaseAuthException catch (e) {
|
||||||
if (e.code == 'invalid-credential' || e.code == 'wrong-password') {
|
if (e.code == 'invalid-credential' || e.code == 'wrong-password') {
|
||||||
throw InvalidCredentialsException(
|
throw InvalidCredentialsException(
|
||||||
technicalMessage: 'Firebase error code: ${e.code}',
|
technicalMessage: 'Firebase error code: ${e.code}',
|
||||||
);
|
);
|
||||||
} else if (e.code == 'network-request-failed') {
|
} else if (e.code == 'network-request-failed') {
|
||||||
throw NetworkException(
|
throw NetworkException(technicalMessage: 'Firebase: ${e.message}');
|
||||||
technicalMessage: 'Firebase: ${e.message}',
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
throw SignInFailedException(
|
throw SignInFailedException(
|
||||||
technicalMessage: 'Firebase auth error: ${e.message}',
|
technicalMessage: 'Firebase auth error: ${e.message}',
|
||||||
@@ -77,9 +65,7 @@ class AuthRepositoryImpl
|
|||||||
} on domain.AppException {
|
} on domain.AppException {
|
||||||
rethrow;
|
rethrow;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw SignInFailedException(
|
throw SignInFailedException(technicalMessage: 'Unexpected error: $e');
|
||||||
technicalMessage: 'Unexpected error: $e',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,10 +80,8 @@ class AuthRepositoryImpl
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Try to create Firebase Auth user
|
// Step 1: Try to create Firebase Auth user
|
||||||
final firebase.UserCredential credential = await _firebaseAuth.createUserWithEmailAndPassword(
|
final firebase.UserCredential credential = await _service.auth
|
||||||
email: email,
|
.createUserWithEmailAndPassword(email: email, password: password);
|
||||||
password: password,
|
|
||||||
);
|
|
||||||
|
|
||||||
firebaseUser = credential.user;
|
firebaseUser = credential.user;
|
||||||
if (firebaseUser == null) {
|
if (firebaseUser == null) {
|
||||||
@@ -111,14 +95,12 @@ class AuthRepositoryImpl
|
|||||||
firebaseUser: firebaseUser,
|
firebaseUser: firebaseUser,
|
||||||
companyName: companyName,
|
companyName: companyName,
|
||||||
email: email,
|
email: email,
|
||||||
onBusinessCreated: (String businessId) => createdBusinessId = businessId,
|
onBusinessCreated: (String businessId) =>
|
||||||
|
createdBusinessId = businessId,
|
||||||
);
|
);
|
||||||
|
|
||||||
} on firebase.FirebaseAuthException catch (e) {
|
} on firebase.FirebaseAuthException catch (e) {
|
||||||
if (e.code == 'weak-password') {
|
if (e.code == 'weak-password') {
|
||||||
throw WeakPasswordException(
|
throw WeakPasswordException(technicalMessage: 'Firebase: ${e.message}');
|
||||||
technicalMessage: 'Firebase: ${e.message}',
|
|
||||||
);
|
|
||||||
} else if (e.code == 'email-already-in-use') {
|
} else if (e.code == 'email-already-in-use') {
|
||||||
// Email exists in Firebase Auth - try to sign in and complete registration
|
// Email exists in Firebase Auth - try to sign in and complete registration
|
||||||
return await _handleExistingFirebaseAccount(
|
return await _handleExistingFirebaseAccount(
|
||||||
@@ -127,9 +109,7 @@ class AuthRepositoryImpl
|
|||||||
companyName: companyName,
|
companyName: companyName,
|
||||||
);
|
);
|
||||||
} else if (e.code == 'network-request-failed') {
|
} else if (e.code == 'network-request-failed') {
|
||||||
throw NetworkException(
|
throw NetworkException(technicalMessage: 'Firebase: ${e.message}');
|
||||||
technicalMessage: 'Firebase: ${e.message}',
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
throw SignUpFailedException(
|
throw SignUpFailedException(
|
||||||
technicalMessage: 'Firebase auth error: ${e.message}',
|
technicalMessage: 'Firebase auth error: ${e.message}',
|
||||||
@@ -137,14 +117,18 @@ class AuthRepositoryImpl
|
|||||||
}
|
}
|
||||||
} on domain.AppException {
|
} on domain.AppException {
|
||||||
// Rollback for our known exceptions
|
// Rollback for our known exceptions
|
||||||
await _rollbackSignUp(firebaseUser: firebaseUser, businessId: createdBusinessId);
|
await _rollbackSignUp(
|
||||||
|
firebaseUser: firebaseUser,
|
||||||
|
businessId: createdBusinessId,
|
||||||
|
);
|
||||||
rethrow;
|
rethrow;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Rollback: Clean up any partially created resources
|
// Rollback: Clean up any partially created resources
|
||||||
await _rollbackSignUp(firebaseUser: firebaseUser, businessId: createdBusinessId);
|
await _rollbackSignUp(
|
||||||
throw SignUpFailedException(
|
firebaseUser: firebaseUser,
|
||||||
technicalMessage: 'Unexpected error: $e',
|
businessId: createdBusinessId,
|
||||||
);
|
);
|
||||||
|
throw SignUpFailedException(technicalMessage: 'Unexpected error: $e');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,14 +148,15 @@ class AuthRepositoryImpl
|
|||||||
required String password,
|
required String password,
|
||||||
required String companyName,
|
required String companyName,
|
||||||
}) async {
|
}) async {
|
||||||
developer.log('Email exists in Firebase, attempting sign-in: $email', name: 'AuthRepository');
|
developer.log(
|
||||||
|
'Email exists in Firebase, attempting sign-in: $email',
|
||||||
|
name: 'AuthRepository',
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Try to sign in with the provided password
|
// Try to sign in with the provided password
|
||||||
final firebase.UserCredential credential = await _firebaseAuth.signInWithEmailAndPassword(
|
final firebase.UserCredential credential = await _service.auth
|
||||||
email: email,
|
.signInWithEmailAndPassword(email: email, password: password);
|
||||||
password: password,
|
|
||||||
);
|
|
||||||
|
|
||||||
final firebase.User? firebaseUser = credential.user;
|
final firebase.User? firebaseUser = credential.user;
|
||||||
if (firebaseUser == null) {
|
if (firebaseUser == null) {
|
||||||
@@ -181,28 +166,40 @@ class AuthRepositoryImpl
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sign-in succeeded! Check if user already has a BUSINESS account in PostgreSQL
|
// Sign-in succeeded! Check if user already has a BUSINESS account in PostgreSQL
|
||||||
final bool hasBusinessAccount = await _checkBusinessUserExists(firebaseUser.uid);
|
final bool hasBusinessAccount = await _checkBusinessUserExists(
|
||||||
|
firebaseUser.uid,
|
||||||
|
);
|
||||||
|
|
||||||
if (hasBusinessAccount) {
|
if (hasBusinessAccount) {
|
||||||
// User already has a KROW Client account
|
// User already has a KROW Client account
|
||||||
developer.log('User already has BUSINESS account: ${firebaseUser.uid}', name: 'AuthRepository');
|
developer.log(
|
||||||
|
'User already has BUSINESS account: ${firebaseUser.uid}',
|
||||||
|
name: 'AuthRepository',
|
||||||
|
);
|
||||||
throw AccountExistsException(
|
throw AccountExistsException(
|
||||||
technicalMessage: 'User ${firebaseUser.uid} already has BUSINESS role',
|
technicalMessage:
|
||||||
|
'User ${firebaseUser.uid} already has BUSINESS role',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// User exists in Firebase but not in KROW PostgreSQL - create the entities
|
// User exists in Firebase but not in KROW PostgreSQL - create the entities
|
||||||
developer.log('Creating BUSINESS account for existing Firebase user: ${firebaseUser.uid}', name: 'AuthRepository');
|
developer.log(
|
||||||
|
'Creating BUSINESS account for existing Firebase user: ${firebaseUser.uid}',
|
||||||
|
name: 'AuthRepository',
|
||||||
|
);
|
||||||
return await _createBusinessAndUser(
|
return await _createBusinessAndUser(
|
||||||
firebaseUser: firebaseUser,
|
firebaseUser: firebaseUser,
|
||||||
companyName: companyName,
|
companyName: companyName,
|
||||||
email: email,
|
email: email,
|
||||||
onBusinessCreated: (_) {}, // No rollback needed for existing Firebase user
|
onBusinessCreated:
|
||||||
|
(_) {}, // No rollback needed for existing Firebase user
|
||||||
);
|
);
|
||||||
|
|
||||||
} on firebase.FirebaseAuthException catch (e) {
|
} on firebase.FirebaseAuthException catch (e) {
|
||||||
// Sign-in failed - check why
|
// Sign-in failed - check why
|
||||||
developer.log('Sign-in failed with code: ${e.code}', name: 'AuthRepository');
|
developer.log(
|
||||||
|
'Sign-in failed with code: ${e.code}',
|
||||||
|
name: 'AuthRepository',
|
||||||
|
);
|
||||||
|
|
||||||
if (e.code == 'wrong-password' || e.code == 'invalid-credential') {
|
if (e.code == 'wrong-password' || e.code == 'invalid-credential') {
|
||||||
// Password doesn't match - check what providers are available
|
// Password doesn't match - check what providers are available
|
||||||
@@ -226,9 +223,13 @@ class AuthRepositoryImpl
|
|||||||
// We can't distinguish between "wrong password" and "no password provider"
|
// We can't distinguish between "wrong password" and "no password provider"
|
||||||
// due to Firebase deprecating fetchSignInMethodsForEmail.
|
// due to Firebase deprecating fetchSignInMethodsForEmail.
|
||||||
// The PasswordMismatchException message covers both scenarios.
|
// The PasswordMismatchException message covers both scenarios.
|
||||||
developer.log('Password mismatch or different provider for: $email', name: 'AuthRepository');
|
developer.log(
|
||||||
|
'Password mismatch or different provider for: $email',
|
||||||
|
name: 'AuthRepository',
|
||||||
|
);
|
||||||
throw PasswordMismatchException(
|
throw PasswordMismatchException(
|
||||||
technicalMessage: 'Email $email: password mismatch or different auth provider',
|
technicalMessage:
|
||||||
|
'Email $email: password mismatch or different auth provider',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,9 +237,12 @@ class AuthRepositoryImpl
|
|||||||
|
|
||||||
Future<bool> _checkBusinessUserExists(String firebaseUserId) async {
|
Future<bool> _checkBusinessUserExists(String firebaseUserId) async {
|
||||||
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response =
|
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response =
|
||||||
await executeProtected(() => _dataConnect.getUserById(id: firebaseUserId).execute());
|
await _service.run(
|
||||||
|
() => _service.connector.getUserById(id: firebaseUserId).execute(),
|
||||||
|
);
|
||||||
final dc.GetUserByIdUser? user = response.data.user;
|
final dc.GetUserByIdUser? user = response.data.user;
|
||||||
return user != null && user.userRole == 'BUSINESS';
|
return user != null &&
|
||||||
|
(user.userRole == 'BUSINESS' || user.userRole == 'BOTH');
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Creates Business and User entities in PostgreSQL for a Firebase user.
|
/// Creates Business and User entities in PostgreSQL for a Firebase user.
|
||||||
@@ -250,29 +254,47 @@ class AuthRepositoryImpl
|
|||||||
}) async {
|
}) async {
|
||||||
// Create Business entity in PostgreSQL
|
// Create Business entity in PostgreSQL
|
||||||
|
|
||||||
final OperationResult<dc.CreateBusinessData, dc.CreateBusinessVariables> createBusinessResponse =
|
final OperationResult<dc.CreateBusinessData, dc.CreateBusinessVariables>
|
||||||
await executeProtected(() => _dataConnect.createBusiness(
|
createBusinessResponse = await _service.run(
|
||||||
businessName: companyName,
|
() => _service.connector
|
||||||
userId: firebaseUser.uid,
|
.createBusiness(
|
||||||
rateGroup: dc.BusinessRateGroup.STANDARD,
|
businessName: companyName,
|
||||||
status: dc.BusinessStatus.PENDING,
|
userId: firebaseUser.uid,
|
||||||
).execute());
|
rateGroup: dc.BusinessRateGroup.STANDARD,
|
||||||
|
status: dc.BusinessStatus.PENDING,
|
||||||
|
)
|
||||||
|
.execute(),
|
||||||
|
);
|
||||||
|
|
||||||
final dc.CreateBusinessBusinessInsert businessData = createBusinessResponse.data.business_insert;
|
final dc.CreateBusinessBusinessInsert businessData =
|
||||||
|
createBusinessResponse.data.business_insert;
|
||||||
onBusinessCreated(businessData.id);
|
onBusinessCreated(businessData.id);
|
||||||
|
|
||||||
// Create User entity in PostgreSQL
|
// Check if User entity already exists in PostgreSQL
|
||||||
|
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> userResult =
|
||||||
|
await _service.run(
|
||||||
|
() => _service.connector.getUserById(id: firebaseUser.uid).execute(),
|
||||||
|
);
|
||||||
|
final dc.GetUserByIdUser? existingUser = userResult.data.user;
|
||||||
|
|
||||||
final OperationResult<dc.CreateUserData, dc.CreateUserVariables> createUserResponse =
|
if (existingUser != null) {
|
||||||
await executeProtected(() => _dataConnect.createUser(
|
// User exists (likely in another app like STAFF). Update role to BOTH.
|
||||||
id: firebaseUser.uid,
|
await _service.run(
|
||||||
role: dc.UserBaseRole.USER,
|
() => _service.connector
|
||||||
)
|
.updateUser(id: firebaseUser.uid)
|
||||||
.email(email)
|
.userRole('BOTH')
|
||||||
.userRole('BUSINESS')
|
.execute(),
|
||||||
.execute());
|
);
|
||||||
|
} else {
|
||||||
final dc.CreateUserUserInsert newUserData = createUserResponse.data.user_insert;
|
// Create new User entity in PostgreSQL
|
||||||
|
await _service.run(
|
||||||
|
() => _service.connector
|
||||||
|
.createUser(id: firebaseUser.uid, role: dc.UserBaseRole.USER)
|
||||||
|
.email(email)
|
||||||
|
.userRole('BUSINESS')
|
||||||
|
.execute(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return _getUserProfile(
|
return _getUserProfile(
|
||||||
firebaseUserId: firebaseUser.uid,
|
firebaseUserId: firebaseUser.uid,
|
||||||
@@ -288,7 +310,7 @@ class AuthRepositoryImpl
|
|||||||
// Delete business first (if created)
|
// Delete business first (if created)
|
||||||
if (businessId != null) {
|
if (businessId != null) {
|
||||||
try {
|
try {
|
||||||
await _dataConnect.deleteBusiness(id: businessId).execute();
|
await _service.connector.deleteBusiness(id: businessId).execute();
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Log but don't throw - we're already in error recovery
|
// Log but don't throw - we're already in error recovery
|
||||||
}
|
}
|
||||||
@@ -306,8 +328,9 @@ class AuthRepositoryImpl
|
|||||||
@override
|
@override
|
||||||
Future<void> signOut() async {
|
Future<void> signOut() async {
|
||||||
try {
|
try {
|
||||||
await _firebaseAuth.signOut();
|
await _service.auth.signOut();
|
||||||
dc.ClientSessionStore.instance.clear();
|
dc.ClientSessionStore.instance.clear();
|
||||||
|
_service.clearCache();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw Exception('Error signing out: ${e.toString()}');
|
throw Exception('Error signing out: ${e.toString()}');
|
||||||
}
|
}
|
||||||
@@ -315,7 +338,9 @@ class AuthRepositoryImpl
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<domain.User> signInWithSocial({required String provider}) {
|
Future<domain.User> signInWithSocial({required String provider}) {
|
||||||
throw UnimplementedError('Social authentication with $provider is not yet implemented.');
|
throw UnimplementedError(
|
||||||
|
'Social authentication with $provider is not yet implemented.',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<domain.User> _getUserProfile({
|
Future<domain.User> _getUserProfile({
|
||||||
@@ -324,18 +349,25 @@ class AuthRepositoryImpl
|
|||||||
bool requireBusinessRole = false,
|
bool requireBusinessRole = false,
|
||||||
}) async {
|
}) async {
|
||||||
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response =
|
final QueryResult<dc.GetUserByIdData, dc.GetUserByIdVariables> response =
|
||||||
await executeProtected(() => _dataConnect.getUserById(id: firebaseUserId).execute());
|
await _service.run(
|
||||||
|
() => _service.connector.getUserById(id: firebaseUserId).execute(),
|
||||||
|
);
|
||||||
final dc.GetUserByIdUser? user = response.data.user;
|
final dc.GetUserByIdUser? user = response.data.user;
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
throw UserNotFoundException(
|
throw UserNotFoundException(
|
||||||
technicalMessage: 'Firebase UID $firebaseUserId not found in users table',
|
technicalMessage:
|
||||||
|
'Firebase UID $firebaseUserId not found in users table',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (requireBusinessRole && user.userRole != 'BUSINESS') {
|
if (requireBusinessRole &&
|
||||||
await _firebaseAuth.signOut();
|
user.userRole != 'BUSINESS' &&
|
||||||
|
user.userRole != 'BOTH') {
|
||||||
|
await _service.auth.signOut();
|
||||||
dc.ClientSessionStore.instance.clear();
|
dc.ClientSessionStore.instance.clear();
|
||||||
|
_service.clearCache();
|
||||||
throw UnauthorizedAppException(
|
throw UnauthorizedAppException(
|
||||||
technicalMessage: 'User role is ${user.userRole}, expected BUSINESS',
|
technicalMessage:
|
||||||
|
'User role is ${user.userRole}, expected BUSINESS or BOTH',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -352,18 +384,22 @@ class AuthRepositoryImpl
|
|||||||
role: user.role.stringValue,
|
role: user.role.stringValue,
|
||||||
);
|
);
|
||||||
|
|
||||||
final QueryResult<dc.GetBusinessesByUserIdData, dc.GetBusinessesByUserIdVariables> businessResponse =
|
final QueryResult<
|
||||||
await executeProtected(() => _dataConnect.getBusinessesByUserId(
|
dc.GetBusinessesByUserIdData,
|
||||||
userId: firebaseUserId,
|
dc.GetBusinessesByUserIdVariables
|
||||||
).execute());
|
>
|
||||||
final dc.GetBusinessesByUserIdBusinesses? business = businessResponse.data.businesses.isNotEmpty
|
businessResponse = await _service.run(
|
||||||
|
() => _service.connector
|
||||||
|
.getBusinessesByUserId(userId: firebaseUserId)
|
||||||
|
.execute(),
|
||||||
|
);
|
||||||
|
final dc.GetBusinessesByUserIdBusinesses? business =
|
||||||
|
businessResponse.data.businesses.isNotEmpty
|
||||||
? businessResponse.data.businesses.first
|
? businessResponse.data.businesses.first
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
dc.ClientSessionStore.instance.setSession(
|
dc.ClientSessionStore.instance.setSession(
|
||||||
dc.ClientSession(
|
dc.ClientSession(
|
||||||
user: domainUser,
|
|
||||||
userPhotoUrl: user.photoUrl,
|
|
||||||
business: business == null
|
business: business == null
|
||||||
? null
|
? null
|
||||||
: dc.ClientBusinessSession(
|
: dc.ClientBusinessSession(
|
||||||
|
|||||||
@@ -27,94 +27,108 @@ class ClientGetStartedPage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
SafeArea(
|
SafeArea(
|
||||||
child: Column(
|
child: LayoutBuilder(
|
||||||
children: <Widget>[
|
builder: (BuildContext context, BoxConstraints constraints) {
|
||||||
const SizedBox(height: UiConstants.space10),
|
return SingleChildScrollView(
|
||||||
// Logo
|
child: ConstrainedBox(
|
||||||
Center(
|
constraints: BoxConstraints(
|
||||||
child: Image.asset(
|
minHeight: constraints.maxHeight,
|
||||||
UiImageAssets.logoBlue,
|
),
|
||||||
height: 40,
|
child: IntrinsicHeight(
|
||||||
fit: BoxFit.contain,
|
child: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
const SizedBox(height: UiConstants.space10),
|
||||||
|
// Logo
|
||||||
|
Center(
|
||||||
|
child: Image.asset(
|
||||||
|
UiImageAssets.logoBlue,
|
||||||
|
height: 40,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const Spacer(),
|
||||||
|
|
||||||
|
// Content Cards Area (Keeping prototype layout)
|
||||||
|
Container(
|
||||||
|
height: 300,
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space6,
|
||||||
|
),
|
||||||
|
child: Stack(
|
||||||
|
children: <Widget>[
|
||||||
|
// Representative cards from prototype
|
||||||
|
Positioned(
|
||||||
|
top: 20,
|
||||||
|
left: 0,
|
||||||
|
right: 20,
|
||||||
|
child: _ShiftOrderCard(),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
bottom: 40,
|
||||||
|
right: 0,
|
||||||
|
left: 40,
|
||||||
|
child: _WorkerProfileCard(),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 60,
|
||||||
|
right: 10,
|
||||||
|
child: _CalendarCard(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const Spacer(),
|
||||||
|
|
||||||
|
// Bottom Content
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space6,
|
||||||
|
vertical: UiConstants.space10,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
t.client_authentication.get_started_page.title,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: UiTypography.displayM,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space3),
|
||||||
|
Text(
|
||||||
|
t.client_authentication.get_started_page
|
||||||
|
.subtitle,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: UiTypography.body2r.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(height: UiConstants.space8),
|
||||||
|
|
||||||
|
// Sign In Button
|
||||||
|
UiButton.primary(
|
||||||
|
text: t.client_authentication.get_started_page
|
||||||
|
.sign_in_button,
|
||||||
|
onPressed: () => Modular.to.toClientSignIn(),
|
||||||
|
fullWidth: true,
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: UiConstants.space3),
|
||||||
|
|
||||||
|
// Create Account Button
|
||||||
|
UiButton.secondary(
|
||||||
|
text: t.client_authentication.get_started_page
|
||||||
|
.create_account_button,
|
||||||
|
onPressed: () => Modular.to.toClientSignUp(),
|
||||||
|
fullWidth: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
|
},
|
||||||
const Spacer(),
|
|
||||||
|
|
||||||
// Content Cards Area (Keeping prototype layout)
|
|
||||||
Container(
|
|
||||||
height: 300,
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: UiConstants.space6,
|
|
||||||
),
|
|
||||||
child: Stack(
|
|
||||||
children: <Widget>[
|
|
||||||
// Representative cards from prototype
|
|
||||||
Positioned(
|
|
||||||
top: 20,
|
|
||||||
left: 0,
|
|
||||||
right: 20,
|
|
||||||
child: _ShiftOrderCard(),
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
bottom: 40,
|
|
||||||
right: 0,
|
|
||||||
left: 40,
|
|
||||||
child: _WorkerProfileCard(),
|
|
||||||
),
|
|
||||||
Positioned(top: 60, right: 10, child: _CalendarCard()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const Spacer(),
|
|
||||||
|
|
||||||
// Bottom Content
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: UiConstants.space6,
|
|
||||||
vertical: UiConstants.space10,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: <Widget>[
|
|
||||||
Text(
|
|
||||||
t.client_authentication.get_started_page.title,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: UiTypography.displayM,
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space3),
|
|
||||||
Text(
|
|
||||||
t.client_authentication.get_started_page.subtitle,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: UiTypography.body2r.textSecondary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space8),
|
|
||||||
|
|
||||||
// Sign In Button
|
|
||||||
UiButton.primary(
|
|
||||||
text: t
|
|
||||||
.client_authentication
|
|
||||||
.get_started_page
|
|
||||||
.sign_in_button,
|
|
||||||
onPressed: () => Modular.to.toClientSignIn(),
|
|
||||||
fullWidth: true,
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: UiConstants.space3),
|
|
||||||
|
|
||||||
// Create Account Button
|
|
||||||
UiButton.secondary(
|
|
||||||
text: t
|
|
||||||
.client_authentication
|
|
||||||
.get_started_page
|
|
||||||
.create_account_button,
|
|
||||||
onPressed: () => Modular.to.toClientSignUp(),
|
|
||||||
fullWidth: true,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ class ClientSignUpPage extends StatelessWidget {
|
|||||||
final TranslationsClientAuthenticationSignUpPageEn i18n = t.client_authentication.sign_up_page;
|
final TranslationsClientAuthenticationSignUpPageEn i18n = t.client_authentication.sign_up_page;
|
||||||
final ClientAuthBloc authBloc = Modular.get<ClientAuthBloc>();
|
final ClientAuthBloc authBloc = Modular.get<ClientAuthBloc>();
|
||||||
|
|
||||||
return BlocProvider.value(
|
return BlocProvider<ClientAuthBloc>.value(
|
||||||
value: authBloc,
|
value: authBloc,
|
||||||
child: BlocConsumer<ClientAuthBloc, ClientAuthState>(
|
child: BlocConsumer<ClientAuthBloc, ClientAuthState>(
|
||||||
listener: (BuildContext context, ClientAuthState state) {
|
listener: (BuildContext context, ClientAuthState state) {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
|
||||||
|
|
||||||
import 'data/repositories_impl/billing_repository_impl.dart';
|
import 'data/repositories_impl/billing_repository_impl.dart';
|
||||||
import 'domain/repositories/billing_repository.dart';
|
import 'domain/repositories/billing_repository.dart';
|
||||||
|
import 'domain/usecases/get_bank_accounts.dart';
|
||||||
import 'domain/usecases/get_current_bill_amount.dart';
|
import 'domain/usecases/get_current_bill_amount.dart';
|
||||||
import 'domain/usecases/get_invoice_history.dart';
|
import 'domain/usecases/get_invoice_history.dart';
|
||||||
import 'domain/usecases/get_pending_invoices.dart';
|
import 'domain/usecases/get_pending_invoices.dart';
|
||||||
@@ -19,13 +19,10 @@ class BillingModule extends Module {
|
|||||||
|
|
||||||
|
|
||||||
// Repositories
|
// Repositories
|
||||||
i.addSingleton<BillingRepository>(
|
i.addSingleton<BillingRepository>(BillingRepositoryImpl.new);
|
||||||
() => BillingRepositoryImpl(
|
|
||||||
dataConnect: ExampleConnector.instance,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use Cases
|
// Use Cases
|
||||||
|
i.addSingleton(GetBankAccountsUseCase.new);
|
||||||
i.addSingleton(GetCurrentBillAmountUseCase.new);
|
i.addSingleton(GetCurrentBillAmountUseCase.new);
|
||||||
i.addSingleton(GetSavingsAmountUseCase.new);
|
i.addSingleton(GetSavingsAmountUseCase.new);
|
||||||
i.addSingleton(GetPendingInvoicesUseCase.new);
|
i.addSingleton(GetPendingInvoicesUseCase.new);
|
||||||
@@ -35,6 +32,7 @@ class BillingModule extends Module {
|
|||||||
// BLoCs
|
// BLoCs
|
||||||
i.addSingleton<BillingBloc>(
|
i.addSingleton<BillingBloc>(
|
||||||
() => BillingBloc(
|
() => BillingBloc(
|
||||||
|
getBankAccounts: i.get<GetBankAccountsUseCase>(),
|
||||||
getCurrentBillAmount: i.get<GetCurrentBillAmountUseCase>(),
|
getCurrentBillAmount: i.get<GetCurrentBillAmountUseCase>(),
|
||||||
getSavingsAmount: i.get<GetSavingsAmountUseCase>(),
|
getSavingsAmount: i.get<GetSavingsAmountUseCase>(),
|
||||||
getPendingInvoices: i.get<GetPendingInvoicesUseCase>(),
|
getPendingInvoices: i.get<GetPendingInvoicesUseCase>(),
|
||||||
|
|||||||
@@ -6,88 +6,95 @@ import '../../domain/repositories/billing_repository.dart';
|
|||||||
|
|
||||||
/// Implementation of [BillingRepository] in the Data layer.
|
/// Implementation of [BillingRepository] in the Data layer.
|
||||||
///
|
///
|
||||||
/// This class is responsible for retrieving billing data from the [FinancialRepositoryMock]
|
/// This class is responsible for retrieving billing data from the
|
||||||
/// (which represents the Data Connect layer) and mapping it to Domain entities.
|
/// Data Connect layer and mapping it to Domain entities.
|
||||||
///
|
class BillingRepositoryImpl implements BillingRepository {
|
||||||
/// It strictly adheres to the Clean Architecture data layer responsibilities:
|
|
||||||
/// - No business logic (except necessary data transformation/filtering).
|
|
||||||
/// - Delegates to data sources.
|
|
||||||
class BillingRepositoryImpl
|
|
||||||
with data_connect.DataErrorHandler
|
|
||||||
implements BillingRepository {
|
|
||||||
/// Creates a [BillingRepositoryImpl].
|
/// Creates a [BillingRepositoryImpl].
|
||||||
///
|
|
||||||
/// Requires the [financialRepository] to fetch financial data.
|
|
||||||
BillingRepositoryImpl({
|
BillingRepositoryImpl({
|
||||||
required data_connect.ExampleConnector dataConnect,
|
data_connect.DataConnectService? service,
|
||||||
}) : _dataConnect = dataConnect;
|
}) : _service = service ?? data_connect.DataConnectService.instance;
|
||||||
|
|
||||||
final data_connect.ExampleConnector _dataConnect;
|
final data_connect.DataConnectService _service;
|
||||||
|
|
||||||
|
/// Fetches bank accounts associated with the business.
|
||||||
|
@override
|
||||||
|
Future<List<BusinessBankAccount>> getBankAccounts() async {
|
||||||
|
return _service.run(() async {
|
||||||
|
final String businessId = await _service.getBusinessId();
|
||||||
|
|
||||||
|
final fdc.QueryResult<
|
||||||
|
data_connect.GetAccountsByOwnerIdData,
|
||||||
|
data_connect.GetAccountsByOwnerIdVariables> result =
|
||||||
|
await _service.connector
|
||||||
|
.getAccountsByOwnerId(ownerId: businessId)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
return result.data.accounts.map(_mapBankAccount).toList();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/// Fetches the current bill amount by aggregating open invoices.
|
/// Fetches the current bill amount by aggregating open invoices.
|
||||||
@override
|
@override
|
||||||
@override
|
|
||||||
Future<double> getCurrentBillAmount() async {
|
Future<double> getCurrentBillAmount() async {
|
||||||
final String? businessId =
|
return _service.run(() async {
|
||||||
data_connect.ClientSessionStore.instance.session?.business?.id;
|
final String businessId = await _service.getBusinessId();
|
||||||
if (businessId == null || businessId.isEmpty) {
|
|
||||||
return 0.0;
|
|
||||||
}
|
|
||||||
|
|
||||||
final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData, data_connect.ListInvoicesByBusinessIdVariables> result = await executeProtected(() => _dataConnect
|
final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData,
|
||||||
.listInvoicesByBusinessId(businessId: businessId)
|
data_connect.ListInvoicesByBusinessIdVariables> result =
|
||||||
.execute());
|
await _service.connector
|
||||||
|
.listInvoicesByBusinessId(businessId: businessId)
|
||||||
|
.execute();
|
||||||
|
|
||||||
return result.data.invoices
|
return result.data.invoices
|
||||||
.map(_mapInvoice)
|
.map(_mapInvoice)
|
||||||
.where((Invoice i) => i.status == InvoiceStatus.open)
|
.where((Invoice i) => i.status == InvoiceStatus.open)
|
||||||
.fold<double>(
|
.fold<double>(
|
||||||
0.0,
|
0.0,
|
||||||
(double sum, Invoice item) => sum + item.totalAmount,
|
(double sum, Invoice item) => sum + item.totalAmount,
|
||||||
);
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches the history of paid invoices.
|
/// Fetches the history of paid invoices.
|
||||||
@override
|
@override
|
||||||
Future<List<Invoice>> getInvoiceHistory() async {
|
Future<List<Invoice>> getInvoiceHistory() async {
|
||||||
final String? businessId =
|
return _service.run(() async {
|
||||||
data_connect.ClientSessionStore.instance.session?.business?.id;
|
final String businessId = await _service.getBusinessId();
|
||||||
if (businessId == null || businessId.isEmpty) {
|
|
||||||
return <Invoice>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData, data_connect.ListInvoicesByBusinessIdVariables> result = await executeProtected(() => _dataConnect
|
final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData,
|
||||||
.listInvoicesByBusinessId(
|
data_connect.ListInvoicesByBusinessIdVariables> result =
|
||||||
businessId: businessId,
|
await _service.connector
|
||||||
)
|
.listInvoicesByBusinessId(
|
||||||
.limit(10)
|
businessId: businessId,
|
||||||
.execute());
|
)
|
||||||
|
.limit(10)
|
||||||
|
.execute();
|
||||||
|
|
||||||
return result.data.invoices.map(_mapInvoice).toList();
|
return result.data.invoices.map(_mapInvoice).toList();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches pending invoices (Open or Disputed).
|
/// Fetches pending invoices (Open or Disputed).
|
||||||
@override
|
@override
|
||||||
@override
|
|
||||||
Future<List<Invoice>> getPendingInvoices() async {
|
Future<List<Invoice>> getPendingInvoices() async {
|
||||||
final String? businessId =
|
return _service.run(() async {
|
||||||
data_connect.ClientSessionStore.instance.session?.business?.id;
|
final String businessId = await _service.getBusinessId();
|
||||||
if (businessId == null || businessId.isEmpty) {
|
|
||||||
return <Invoice>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData, data_connect.ListInvoicesByBusinessIdVariables> result = await executeProtected(() => _dataConnect
|
final fdc.QueryResult<data_connect.ListInvoicesByBusinessIdData,
|
||||||
.listInvoicesByBusinessId(businessId: businessId)
|
data_connect.ListInvoicesByBusinessIdVariables> result =
|
||||||
.execute());
|
await _service.connector
|
||||||
|
.listInvoicesByBusinessId(businessId: businessId)
|
||||||
|
.execute();
|
||||||
|
|
||||||
return result.data.invoices
|
return result.data.invoices
|
||||||
.map(_mapInvoice)
|
.map(_mapInvoice)
|
||||||
.where(
|
.where(
|
||||||
(Invoice i) =>
|
(Invoice i) =>
|
||||||
i.status == InvoiceStatus.open ||
|
i.status == InvoiceStatus.open ||
|
||||||
i.status == InvoiceStatus.disputed,
|
i.status == InvoiceStatus.disputed,
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches the estimated savings amount.
|
/// Fetches the estimated savings amount.
|
||||||
@@ -101,86 +108,81 @@ class BillingRepositoryImpl
|
|||||||
/// Fetches the breakdown of spending.
|
/// Fetches the breakdown of spending.
|
||||||
@override
|
@override
|
||||||
Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period) async {
|
Future<List<InvoiceItem>> getSpendingBreakdown(BillingPeriod period) async {
|
||||||
final String? businessId =
|
return _service.run(() async {
|
||||||
data_connect.ClientSessionStore.instance.session?.business?.id;
|
final String businessId = await _service.getBusinessId();
|
||||||
if (businessId == null || businessId.isEmpty) {
|
|
||||||
return <InvoiceItem>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
final DateTime now = DateTime.now();
|
final DateTime now = DateTime.now();
|
||||||
final DateTime start;
|
final DateTime start;
|
||||||
final DateTime end;
|
final DateTime end;
|
||||||
if (period == BillingPeriod.week) {
|
if (period == BillingPeriod.week) {
|
||||||
final int daysFromMonday = now.weekday - DateTime.monday;
|
final int daysFromMonday = now.weekday - DateTime.monday;
|
||||||
final DateTime monday = DateTime(
|
final DateTime monday = DateTime(
|
||||||
now.year,
|
now.year,
|
||||||
now.month,
|
now.month,
|
||||||
now.day,
|
now.day,
|
||||||
).subtract(Duration(days: daysFromMonday));
|
).subtract(Duration(days: daysFromMonday));
|
||||||
start = DateTime(monday.year, monday.month, monday.day);
|
start = DateTime(monday.year, monday.month, monday.day);
|
||||||
end = DateTime(monday.year, monday.month, monday.day + 6, 23, 59, 59, 999);
|
end = DateTime(
|
||||||
} else {
|
monday.year, monday.month, monday.day + 6, 23, 59, 59, 999);
|
||||||
start = DateTime(now.year, now.month, 1);
|
|
||||||
end = DateTime(now.year, now.month + 1, 0, 23, 59, 59, 999);
|
|
||||||
}
|
|
||||||
|
|
||||||
final fdc.QueryResult<data_connect.ListShiftRolesByBusinessAndDatesSummaryData, data_connect.ListShiftRolesByBusinessAndDatesSummaryVariables> result = await executeProtected(() => _dataConnect
|
|
||||||
.listShiftRolesByBusinessAndDatesSummary(
|
|
||||||
businessId: businessId,
|
|
||||||
start: _toTimestamp(start),
|
|
||||||
end: _toTimestamp(end),
|
|
||||||
)
|
|
||||||
.execute());
|
|
||||||
|
|
||||||
final List<data_connect.ListShiftRolesByBusinessAndDatesSummaryShiftRoles>
|
|
||||||
shiftRoles = result.data.shiftRoles;
|
|
||||||
if (shiftRoles.isEmpty) {
|
|
||||||
return <InvoiceItem>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<String, _RoleSummary> summary = <String, _RoleSummary>{};
|
|
||||||
for (final data_connect.ListShiftRolesByBusinessAndDatesSummaryShiftRoles role
|
|
||||||
in shiftRoles) {
|
|
||||||
final String roleId = role.roleId;
|
|
||||||
final String roleName = role.role.name;
|
|
||||||
final double hours = role.hours ?? 0.0;
|
|
||||||
final double totalValue = role.totalValue ?? 0.0;
|
|
||||||
final _RoleSummary? existing = summary[roleId];
|
|
||||||
if (existing == null) {
|
|
||||||
summary[roleId] = _RoleSummary(
|
|
||||||
roleId: roleId,
|
|
||||||
roleName: roleName,
|
|
||||||
totalHours: hours,
|
|
||||||
totalValue: totalValue,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
summary[roleId] = existing.copyWith(
|
start = DateTime(now.year, now.month, 1);
|
||||||
totalHours: existing.totalHours + hours,
|
end = DateTime(now.year, now.month + 1, 0, 23, 59, 59, 999);
|
||||||
totalValue: existing.totalValue + totalValue,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return summary.values
|
final fdc.QueryResult<
|
||||||
.map(
|
data_connect.ListShiftRolesByBusinessAndDatesSummaryData,
|
||||||
(_RoleSummary item) => InvoiceItem(
|
data_connect.ListShiftRolesByBusinessAndDatesSummaryVariables>
|
||||||
id: item.roleId,
|
result = await _service.connector
|
||||||
invoiceId: item.roleId,
|
.listShiftRolesByBusinessAndDatesSummary(
|
||||||
staffId: item.roleName,
|
businessId: businessId,
|
||||||
workHours: item.totalHours,
|
start: _service.toTimestamp(start),
|
||||||
rate: item.totalHours > 0 ? item.totalValue / item.totalHours : 0,
|
end: _service.toTimestamp(end),
|
||||||
amount: item.totalValue,
|
)
|
||||||
),
|
.execute();
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
fdc.Timestamp _toTimestamp(DateTime dateTime) {
|
final List<data_connect.ListShiftRolesByBusinessAndDatesSummaryShiftRoles>
|
||||||
final DateTime utc = dateTime.toUtc();
|
shiftRoles = result.data.shiftRoles;
|
||||||
final int seconds = utc.millisecondsSinceEpoch ~/ 1000;
|
if (shiftRoles.isEmpty) {
|
||||||
final int nanoseconds =
|
return <InvoiceItem>[];
|
||||||
(utc.millisecondsSinceEpoch % 1000) * 1000000;
|
}
|
||||||
return fdc.Timestamp(nanoseconds, seconds);
|
|
||||||
|
final Map<String, _RoleSummary> summary = <String, _RoleSummary>{};
|
||||||
|
for (final data_connect
|
||||||
|
.ListShiftRolesByBusinessAndDatesSummaryShiftRoles role
|
||||||
|
in shiftRoles) {
|
||||||
|
final String roleId = role.roleId;
|
||||||
|
final String roleName = role.role.name;
|
||||||
|
final double hours = role.hours ?? 0.0;
|
||||||
|
final double totalValue = role.totalValue ?? 0.0;
|
||||||
|
final _RoleSummary? existing = summary[roleId];
|
||||||
|
if (existing == null) {
|
||||||
|
summary[roleId] = _RoleSummary(
|
||||||
|
roleId: roleId,
|
||||||
|
roleName: roleName,
|
||||||
|
totalHours: hours,
|
||||||
|
totalValue: totalValue,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
summary[roleId] = existing.copyWith(
|
||||||
|
totalHours: existing.totalHours + hours,
|
||||||
|
totalValue: existing.totalValue + totalValue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary.values
|
||||||
|
.map(
|
||||||
|
(_RoleSummary item) => InvoiceItem(
|
||||||
|
id: item.roleId,
|
||||||
|
invoiceId: item.roleId,
|
||||||
|
staffId: item.roleName,
|
||||||
|
workHours: item.totalHours,
|
||||||
|
rate: item.totalHours > 0 ? item.totalValue / item.totalHours : 0,
|
||||||
|
amount: item.totalValue,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Invoice _mapInvoice(data_connect.ListInvoicesByBusinessIdInvoices invoice) {
|
Invoice _mapInvoice(data_connect.ListInvoicesByBusinessIdInvoices invoice) {
|
||||||
@@ -193,7 +195,19 @@ class BillingRepositoryImpl
|
|||||||
workAmount: invoice.amount,
|
workAmount: invoice.amount,
|
||||||
addonsAmount: invoice.otherCharges ?? 0,
|
addonsAmount: invoice.otherCharges ?? 0,
|
||||||
invoiceNumber: invoice.invoiceNumber,
|
invoiceNumber: invoice.invoiceNumber,
|
||||||
issueDate: invoice.issueDate.toDateTime(),
|
issueDate: _service.toDateTime(invoice.issueDate)!,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
BusinessBankAccount _mapBankAccount(
|
||||||
|
data_connect.GetAccountsByOwnerIdAccounts account,
|
||||||
|
) {
|
||||||
|
return BusinessBankAccountAdapter.fromPrimitives(
|
||||||
|
id: account.id,
|
||||||
|
bank: account.bank,
|
||||||
|
last4: account.last4,
|
||||||
|
isPrimary: account.isPrimary ?? false,
|
||||||
|
expiryTime: _service.toDateTime(account.expiryTime),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import '../models/billing_period.dart';
|
|||||||
/// acting as a boundary between the Domain and Data layers.
|
/// acting as a boundary between the Domain and Data layers.
|
||||||
/// It allows the Domain layer to remain independent of specific data sources.
|
/// It allows the Domain layer to remain independent of specific data sources.
|
||||||
abstract class BillingRepository {
|
abstract class BillingRepository {
|
||||||
|
/// Fetches bank accounts associated with the business.
|
||||||
|
Future<List<BusinessBankAccount>> getBankAccounts();
|
||||||
|
|
||||||
/// Fetches invoices that are pending approval or payment.
|
/// Fetches invoices that are pending approval or payment.
|
||||||
Future<List<Invoice>> getPendingInvoices();
|
Future<List<Invoice>> getPendingInvoices();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
import '../repositories/billing_repository.dart';
|
||||||
|
|
||||||
|
/// Use case for fetching the bank accounts associated with the business.
|
||||||
|
class GetBankAccountsUseCase extends NoInputUseCase<List<BusinessBankAccount>> {
|
||||||
|
/// Creates a [GetBankAccountsUseCase].
|
||||||
|
GetBankAccountsUseCase(this._repository);
|
||||||
|
|
||||||
|
final BillingRepository _repository;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<List<BusinessBankAccount>> call() => _repository.getBankAccounts();
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
import '../../domain/usecases/get_bank_accounts.dart';
|
||||||
import '../../domain/usecases/get_current_bill_amount.dart';
|
import '../../domain/usecases/get_current_bill_amount.dart';
|
||||||
import '../../domain/usecases/get_invoice_history.dart';
|
import '../../domain/usecases/get_invoice_history.dart';
|
||||||
import '../../domain/usecases/get_pending_invoices.dart';
|
import '../../domain/usecases/get_pending_invoices.dart';
|
||||||
@@ -16,12 +17,14 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
|||||||
with BlocErrorHandler<BillingState> {
|
with BlocErrorHandler<BillingState> {
|
||||||
/// Creates a [BillingBloc] with the given use cases.
|
/// Creates a [BillingBloc] with the given use cases.
|
||||||
BillingBloc({
|
BillingBloc({
|
||||||
|
required GetBankAccountsUseCase getBankAccounts,
|
||||||
required GetCurrentBillAmountUseCase getCurrentBillAmount,
|
required GetCurrentBillAmountUseCase getCurrentBillAmount,
|
||||||
required GetSavingsAmountUseCase getSavingsAmount,
|
required GetSavingsAmountUseCase getSavingsAmount,
|
||||||
required GetPendingInvoicesUseCase getPendingInvoices,
|
required GetPendingInvoicesUseCase getPendingInvoices,
|
||||||
required GetInvoiceHistoryUseCase getInvoiceHistory,
|
required GetInvoiceHistoryUseCase getInvoiceHistory,
|
||||||
required GetSpendingBreakdownUseCase getSpendingBreakdown,
|
required GetSpendingBreakdownUseCase getSpendingBreakdown,
|
||||||
}) : _getCurrentBillAmount = getCurrentBillAmount,
|
}) : _getBankAccounts = getBankAccounts,
|
||||||
|
_getCurrentBillAmount = getCurrentBillAmount,
|
||||||
_getSavingsAmount = getSavingsAmount,
|
_getSavingsAmount = getSavingsAmount,
|
||||||
_getPendingInvoices = getPendingInvoices,
|
_getPendingInvoices = getPendingInvoices,
|
||||||
_getInvoiceHistory = getInvoiceHistory,
|
_getInvoiceHistory = getInvoiceHistory,
|
||||||
@@ -31,6 +34,7 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
|||||||
on<BillingPeriodChanged>(_onPeriodChanged);
|
on<BillingPeriodChanged>(_onPeriodChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final GetBankAccountsUseCase _getBankAccounts;
|
||||||
final GetCurrentBillAmountUseCase _getCurrentBillAmount;
|
final GetCurrentBillAmountUseCase _getCurrentBillAmount;
|
||||||
final GetSavingsAmountUseCase _getSavingsAmount;
|
final GetSavingsAmountUseCase _getSavingsAmount;
|
||||||
final GetPendingInvoicesUseCase _getPendingInvoices;
|
final GetPendingInvoicesUseCase _getPendingInvoices;
|
||||||
@@ -52,13 +56,15 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
|||||||
_getPendingInvoices.call(),
|
_getPendingInvoices.call(),
|
||||||
_getInvoiceHistory.call(),
|
_getInvoiceHistory.call(),
|
||||||
_getSpendingBreakdown.call(state.period),
|
_getSpendingBreakdown.call(state.period),
|
||||||
|
_getBankAccounts.call(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
final double currentBill = results[0] as double;
|
|
||||||
final double savings = results[1] as double;
|
final double savings = results[1] as double;
|
||||||
final List<Invoice> pendingInvoices = results[2] as List<Invoice>;
|
final List<Invoice> pendingInvoices = results[2] as List<Invoice>;
|
||||||
final List<Invoice> invoiceHistory = results[3] as List<Invoice>;
|
final List<Invoice> invoiceHistory = results[3] as List<Invoice>;
|
||||||
final List<InvoiceItem> spendingItems = results[4] as List<InvoiceItem>;
|
final List<InvoiceItem> spendingItems = results[4] as List<InvoiceItem>;
|
||||||
|
final List<BusinessBankAccount> bankAccounts =
|
||||||
|
results[5] as List<BusinessBankAccount>;
|
||||||
|
|
||||||
// Map Domain Entities to Presentation Models
|
// Map Domain Entities to Presentation Models
|
||||||
final List<BillingInvoice> uiPendingInvoices =
|
final List<BillingInvoice> uiPendingInvoices =
|
||||||
@@ -80,6 +86,7 @@ class BillingBloc extends Bloc<BillingEvent, BillingState>
|
|||||||
pendingInvoices: uiPendingInvoices,
|
pendingInvoices: uiPendingInvoices,
|
||||||
invoiceHistory: uiInvoiceHistory,
|
invoiceHistory: uiInvoiceHistory,
|
||||||
spendingBreakdown: uiSpendingBreakdown,
|
spendingBreakdown: uiSpendingBreakdown,
|
||||||
|
bankAccounts: bankAccounts,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:equatable/equatable.dart';
|
import 'package:equatable/equatable.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
import '../../domain/models/billing_period.dart';
|
import '../../domain/models/billing_period.dart';
|
||||||
import '../models/billing_invoice_model.dart';
|
import '../models/billing_invoice_model.dart';
|
||||||
import '../models/spending_breakdown_model.dart';
|
import '../models/spending_breakdown_model.dart';
|
||||||
@@ -28,6 +29,7 @@ class BillingState extends Equatable {
|
|||||||
this.pendingInvoices = const <BillingInvoice>[],
|
this.pendingInvoices = const <BillingInvoice>[],
|
||||||
this.invoiceHistory = const <BillingInvoice>[],
|
this.invoiceHistory = const <BillingInvoice>[],
|
||||||
this.spendingBreakdown = const <SpendingBreakdownItem>[],
|
this.spendingBreakdown = const <SpendingBreakdownItem>[],
|
||||||
|
this.bankAccounts = const <BusinessBankAccount>[],
|
||||||
this.period = BillingPeriod.week,
|
this.period = BillingPeriod.week,
|
||||||
this.errorMessage,
|
this.errorMessage,
|
||||||
});
|
});
|
||||||
@@ -50,6 +52,9 @@ class BillingState extends Equatable {
|
|||||||
/// Breakdown of spending by category.
|
/// Breakdown of spending by category.
|
||||||
final List<SpendingBreakdownItem> spendingBreakdown;
|
final List<SpendingBreakdownItem> spendingBreakdown;
|
||||||
|
|
||||||
|
/// Bank accounts associated with the business.
|
||||||
|
final List<BusinessBankAccount> bankAccounts;
|
||||||
|
|
||||||
/// Selected period for the breakdown.
|
/// Selected period for the breakdown.
|
||||||
final BillingPeriod period;
|
final BillingPeriod period;
|
||||||
|
|
||||||
@@ -64,6 +69,7 @@ class BillingState extends Equatable {
|
|||||||
List<BillingInvoice>? pendingInvoices,
|
List<BillingInvoice>? pendingInvoices,
|
||||||
List<BillingInvoice>? invoiceHistory,
|
List<BillingInvoice>? invoiceHistory,
|
||||||
List<SpendingBreakdownItem>? spendingBreakdown,
|
List<SpendingBreakdownItem>? spendingBreakdown,
|
||||||
|
List<BusinessBankAccount>? bankAccounts,
|
||||||
BillingPeriod? period,
|
BillingPeriod? period,
|
||||||
String? errorMessage,
|
String? errorMessage,
|
||||||
}) {
|
}) {
|
||||||
@@ -74,6 +80,7 @@ class BillingState extends Equatable {
|
|||||||
pendingInvoices: pendingInvoices ?? this.pendingInvoices,
|
pendingInvoices: pendingInvoices ?? this.pendingInvoices,
|
||||||
invoiceHistory: invoiceHistory ?? this.invoiceHistory,
|
invoiceHistory: invoiceHistory ?? this.invoiceHistory,
|
||||||
spendingBreakdown: spendingBreakdown ?? this.spendingBreakdown,
|
spendingBreakdown: spendingBreakdown ?? this.spendingBreakdown,
|
||||||
|
bankAccounts: bankAccounts ?? this.bankAccounts,
|
||||||
period: period ?? this.period,
|
period: period ?? this.period,
|
||||||
errorMessage: errorMessage ?? this.errorMessage,
|
errorMessage: errorMessage ?? this.errorMessage,
|
||||||
);
|
);
|
||||||
@@ -87,6 +94,7 @@ class BillingState extends Equatable {
|
|||||||
pendingInvoices,
|
pendingInvoices,
|
||||||
invoiceHistory,
|
invoiceHistory,
|
||||||
spendingBreakdown,
|
spendingBreakdown,
|
||||||
|
bankAccounts,
|
||||||
period,
|
period,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -71,19 +71,20 @@ class _BillingViewState extends State<BillingView> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BlocConsumer<BillingBloc, BillingState>(
|
return Scaffold(
|
||||||
listener: (BuildContext context, BillingState state) {
|
body: BlocConsumer<BillingBloc, BillingState>(
|
||||||
if (state.status == BillingStatus.failure && state.errorMessage != null) {
|
listener: (BuildContext context, BillingState state) {
|
||||||
UiSnackbar.show(
|
if (state.status == BillingStatus.failure &&
|
||||||
context,
|
state.errorMessage != null) {
|
||||||
message: translateErrorKey(state.errorMessage!),
|
UiSnackbar.show(
|
||||||
type: UiSnackbarType.error,
|
context,
|
||||||
);
|
message: translateErrorKey(state.errorMessage!),
|
||||||
}
|
type: UiSnackbarType.error,
|
||||||
},
|
);
|
||||||
builder: (BuildContext context, BillingState state) {
|
}
|
||||||
return Scaffold(
|
},
|
||||||
body: CustomScrollView(
|
builder: (BuildContext context, BillingState state) {
|
||||||
|
return CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
slivers: <Widget>[
|
slivers: <Widget>[
|
||||||
SliverAppBar(
|
SliverAppBar(
|
||||||
@@ -97,7 +98,7 @@ class _BillingViewState extends State<BillingView> {
|
|||||||
leading: Center(
|
leading: Center(
|
||||||
child: UiIconButton.secondary(
|
child: UiIconButton.secondary(
|
||||||
icon: UiIcons.arrowLeft,
|
icon: UiIcons.arrowLeft,
|
||||||
onTap: () => Modular.to.toClientHome()
|
onTap: () => Modular.to.toClientHome(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
title: AnimatedSwitcher(
|
title: AnimatedSwitcher(
|
||||||
@@ -132,8 +133,9 @@ class _BillingViewState extends State<BillingView> {
|
|||||||
const SizedBox(height: UiConstants.space1),
|
const SizedBox(height: UiConstants.space1),
|
||||||
Text(
|
Text(
|
||||||
'\$${state.currentBill.toStringAsFixed(2)}',
|
'\$${state.currentBill.toStringAsFixed(2)}',
|
||||||
style: UiTypography.display1b
|
style: UiTypography.display1b.copyWith(
|
||||||
.copyWith(color: UiColors.white),
|
color: UiColors.white,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: UiConstants.space2),
|
const SizedBox(height: UiConstants.space2),
|
||||||
Container(
|
Container(
|
||||||
@@ -171,16 +173,14 @@ class _BillingViewState extends State<BillingView> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
SliverList(
|
SliverList(
|
||||||
delegate: SliverChildListDelegate(
|
delegate: SliverChildListDelegate(<Widget>[
|
||||||
<Widget>[
|
_buildContent(context, state),
|
||||||
_buildContent(context, state),
|
]),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,7 +211,9 @@ class _BillingViewState extends State<BillingView> {
|
|||||||
const SizedBox(height: UiConstants.space4),
|
const SizedBox(height: UiConstants.space4),
|
||||||
UiButton.secondary(
|
UiButton.secondary(
|
||||||
text: 'Retry',
|
text: 'Retry',
|
||||||
onPressed: () => BlocProvider.of<BillingBloc>(context).add(const BillingLoadStarted()),
|
onPressed: () => BlocProvider.of<BillingBloc>(
|
||||||
|
context,
|
||||||
|
).add(const BillingLoadStarted()),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -230,8 +232,10 @@ class _BillingViewState extends State<BillingView> {
|
|||||||
],
|
],
|
||||||
const PaymentMethodCard(),
|
const PaymentMethodCard(),
|
||||||
const SpendingBreakdownCard(),
|
const SpendingBreakdownCard(),
|
||||||
if (state.invoiceHistory.isEmpty) _buildEmptyState(context)
|
if (state.invoiceHistory.isEmpty)
|
||||||
else InvoiceHistorySection(invoices: state.invoiceHistory),
|
_buildEmptyState(context)
|
||||||
|
else
|
||||||
|
InvoiceHistorySection(invoices: state.invoiceHistory),
|
||||||
|
|
||||||
const SizedBox(height: UiConstants.space32),
|
const SizedBox(height: UiConstants.space32),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,166 +1,133 @@
|
|||||||
import 'package:core_localization/core_localization.dart';
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||||
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
import '../blocs/billing_bloc.dart';
|
||||||
|
import '../blocs/billing_state.dart';
|
||||||
|
|
||||||
/// Card showing the current payment method.
|
/// Card showing the current payment method.
|
||||||
class PaymentMethodCard extends StatefulWidget {
|
class PaymentMethodCard extends StatelessWidget {
|
||||||
/// Creates a [PaymentMethodCard].
|
/// Creates a [PaymentMethodCard].
|
||||||
const PaymentMethodCard({super.key});
|
const PaymentMethodCard({super.key});
|
||||||
|
|
||||||
@override
|
|
||||||
State<PaymentMethodCard> createState() => _PaymentMethodCardState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _PaymentMethodCardState extends State<PaymentMethodCard> {
|
|
||||||
late final Future<dc.GetAccountsByOwnerIdData?> _accountsFuture =
|
|
||||||
_loadAccounts();
|
|
||||||
|
|
||||||
Future<dc.GetAccountsByOwnerIdData?> _loadAccounts() async {
|
|
||||||
final String? businessId =
|
|
||||||
dc.ClientSessionStore.instance.session?.business?.id;
|
|
||||||
if (businessId == null || businessId.isEmpty) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
final fdc.QueryResult<
|
|
||||||
dc.GetAccountsByOwnerIdData,
|
|
||||||
dc.GetAccountsByOwnerIdVariables
|
|
||||||
>
|
|
||||||
result = await dc.ExampleConnector.instance
|
|
||||||
.getAccountsByOwnerId(ownerId: businessId)
|
|
||||||
.execute();
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return FutureBuilder<dc.GetAccountsByOwnerIdData?>(
|
return BlocBuilder<BillingBloc, BillingState>(
|
||||||
future: _accountsFuture,
|
builder: (BuildContext context, BillingState state) {
|
||||||
builder:
|
final List<BusinessBankAccount> accounts = state.bankAccounts;
|
||||||
(
|
final BusinessBankAccount? account =
|
||||||
BuildContext context,
|
accounts.isNotEmpty ? accounts.first : null;
|
||||||
AsyncSnapshot<dc.GetAccountsByOwnerIdData?> snapshot,
|
|
||||||
) {
|
|
||||||
final List<dc.GetAccountsByOwnerIdAccounts> accounts =
|
|
||||||
snapshot.data?.accounts ?? <dc.GetAccountsByOwnerIdAccounts>[];
|
|
||||||
final dc.GetAccountsByOwnerIdAccounts? account = accounts.isNotEmpty
|
|
||||||
? accounts.first
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (account == null) {
|
if (account == null) {
|
||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
final String bankLabel = account.bank.isNotEmpty == true
|
final String bankLabel =
|
||||||
? account.bank
|
account.bankName.isNotEmpty == true ? account.bankName : '----';
|
||||||
: '----';
|
final String last4 =
|
||||||
final String last4 = account.last4.isNotEmpty == true
|
account.last4.isNotEmpty == true ? account.last4 : '----';
|
||||||
? account.last4
|
final bool isPrimary = account.isPrimary;
|
||||||
: '----';
|
final String expiryLabel = _formatExpiry(account.expiryTime);
|
||||||
final bool isPrimary = account.isPrimary ?? false;
|
|
||||||
final String expiryLabel = _formatExpiry(account.expiryTime);
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(UiConstants.space4),
|
padding: const EdgeInsets.all(UiConstants.space4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: UiColors.white,
|
color: UiColors.white,
|
||||||
borderRadius: UiConstants.radiusLg,
|
borderRadius: UiConstants.radiusLg,
|
||||||
border: Border.all(color: UiColors.border),
|
border: Border.all(color: UiColors.border),
|
||||||
boxShadow: <BoxShadow>[
|
boxShadow: <BoxShadow>[
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: UiColors.black.withValues(alpha: 0.04),
|
color: UiColors.black.withValues(alpha: 0.04),
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 2),
|
offset: const Offset(0, 2),
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: Column(
|
],
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Row(
|
Text(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
t.client_billing.payment_method,
|
||||||
children: <Widget>[
|
style: UiTypography.title2b.textPrimary,
|
||||||
Text(
|
|
||||||
t.client_billing.payment_method,
|
|
||||||
style: UiTypography.title2b.textPrimary,
|
|
||||||
),
|
|
||||||
const SizedBox.shrink(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: UiConstants.space3),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(UiConstants.space3),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.bgSecondary,
|
|
||||||
borderRadius: UiConstants.radiusMd,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: <Widget>[
|
|
||||||
Container(
|
|
||||||
width: UiConstants.space10,
|
|
||||||
height: UiConstants.space6 + 4,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.primary,
|
|
||||||
borderRadius: UiConstants.radiusSm,
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
bankLabel,
|
|
||||||
style: UiTypography.footnote2b.white,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: UiConstants.space3),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: <Widget>[
|
|
||||||
Text(
|
|
||||||
'•••• $last4',
|
|
||||||
style: UiTypography.body2b.textPrimary,
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
t.client_billing.expires(date: expiryLabel),
|
|
||||||
style: UiTypography.footnote2r.textSecondary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isPrimary)
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: UiConstants.space2,
|
|
||||||
vertical: UiConstants.space1,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: UiColors.accent,
|
|
||||||
borderRadius: UiConstants.radiusSm,
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
t.client_billing.default_badge,
|
|
||||||
style: UiTypography.titleUppercase4b.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
const SizedBox.shrink(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
const SizedBox(height: UiConstants.space3),
|
||||||
},
|
Container(
|
||||||
|
padding: const EdgeInsets.all(UiConstants.space3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.bgSecondary,
|
||||||
|
borderRadius: UiConstants.radiusMd,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: <Widget>[
|
||||||
|
Container(
|
||||||
|
width: UiConstants.space10,
|
||||||
|
height: UiConstants.space6 + 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.primary,
|
||||||
|
borderRadius: UiConstants.radiusSm,
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
bankLabel,
|
||||||
|
style: UiTypography.footnote2b.white,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: UiConstants.space3),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: <Widget>[
|
||||||
|
Text(
|
||||||
|
'•••• $last4',
|
||||||
|
style: UiTypography.body2b.textPrimary,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
t.client_billing.expires(date: expiryLabel),
|
||||||
|
style: UiTypography.footnote2r.textSecondary,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isPrimary)
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: UiConstants.space2,
|
||||||
|
vertical: UiConstants.space1,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: UiColors.accent,
|
||||||
|
borderRadius: UiConstants.radiusSm,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
t.client_billing.default_badge,
|
||||||
|
style: UiTypography.titleUppercase4b.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatExpiry(fdc.Timestamp? expiryTime) {
|
String _formatExpiry(DateTime? expiryTime) {
|
||||||
if (expiryTime == null) {
|
if (expiryTime == null) {
|
||||||
return 'N/A';
|
return 'N/A';
|
||||||
}
|
}
|
||||||
final DateTime date = expiryTime.toDateTime();
|
final String month = expiryTime.month.toString().padLeft(2, '0');
|
||||||
final String month = date.month.toString().padLeft(2, '0');
|
final String year = (expiryTime.year % 100).toString().padLeft(2, '0');
|
||||||
final String year = (date.year % 100).toString().padLeft(2, '0');
|
|
||||||
return '$month/$year';
|
return '$month/$year';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,24 +10,20 @@ import 'presentation/pages/coverage_page.dart';
|
|||||||
|
|
||||||
/// Modular module for the coverage feature.
|
/// Modular module for the coverage feature.
|
||||||
class CoverageModule extends Module {
|
class CoverageModule extends Module {
|
||||||
|
@override
|
||||||
|
List<Module> get imports => <Module>[DataConnectModule()];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
// Repositories
|
// Repositories
|
||||||
i.addSingleton<CoverageRepository>(
|
i.addSingleton<CoverageRepository>(CoverageRepositoryImpl.new);
|
||||||
() => CoverageRepositoryImpl(dataConnect: ExampleConnector.instance),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use Cases
|
// Use Cases
|
||||||
i.addSingleton(GetShiftsForDateUseCase.new);
|
i.addSingleton(GetShiftsForDateUseCase.new);
|
||||||
i.addSingleton(GetCoverageStatsUseCase.new);
|
i.addSingleton(GetCoverageStatsUseCase.new);
|
||||||
|
|
||||||
// BLoCs
|
// BLoCs
|
||||||
i.addSingleton<CoverageBloc>(
|
i.addSingleton<CoverageBloc>(CoverageBloc.new);
|
||||||
() => CoverageBloc(
|
|
||||||
getShiftsForDate: i.get<GetShiftsForDateUseCase>(),
|
|
||||||
getCoverageStats: i.get<GetCoverageStatsUseCase>(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -15,44 +15,36 @@ import '../../domain/repositories/coverage_repository.dart';
|
|||||||
/// - Returns domain entities from `domain/ui_entities`.
|
/// - Returns domain entities from `domain/ui_entities`.
|
||||||
class CoverageRepositoryImpl implements CoverageRepository {
|
class CoverageRepositoryImpl implements CoverageRepository {
|
||||||
/// Creates a [CoverageRepositoryImpl].
|
/// Creates a [CoverageRepositoryImpl].
|
||||||
CoverageRepositoryImpl({required dc.ExampleConnector dataConnect})
|
CoverageRepositoryImpl({required dc.DataConnectService service}) : _service = service;
|
||||||
: _dataConnect = dataConnect;
|
|
||||||
|
|
||||||
final dc.ExampleConnector _dataConnect;
|
final dc.DataConnectService _service;
|
||||||
|
|
||||||
/// Fetches shifts for a specific date.
|
/// Fetches shifts for a specific date.
|
||||||
@override
|
@override
|
||||||
Future<List<CoverageShift>> getShiftsForDate({required DateTime date}) async {
|
Future<List<CoverageShift>> getShiftsForDate({required DateTime date}) async {
|
||||||
try {
|
return _service.run(() async {
|
||||||
final String? businessId =
|
final String businessId = await _service.getBusinessId();
|
||||||
dc.ClientSessionStore.instance.session?.business?.id;
|
|
||||||
if (businessId == null || businessId.isEmpty) {
|
|
||||||
return <CoverageShift>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
final DateTime start = DateTime(date.year, date.month, date.day);
|
final DateTime start = DateTime(date.year, date.month, date.day);
|
||||||
final DateTime end =
|
final DateTime end = DateTime(date.year, date.month, date.day, 23, 59, 59, 999);
|
||||||
DateTime(date.year, date.month, date.day, 23, 59, 59, 999);
|
|
||||||
|
|
||||||
final fdc.QueryResult<
|
final fdc.QueryResult<dc.ListShiftRolesByBusinessAndDateRangeData,
|
||||||
dc.ListShiftRolesByBusinessAndDateRangeData,
|
dc.ListShiftRolesByBusinessAndDateRangeVariables> shiftRolesResult =
|
||||||
dc.ListShiftRolesByBusinessAndDateRangeVariables> shiftRolesResult =
|
await _service.connector
|
||||||
await _dataConnect
|
|
||||||
.listShiftRolesByBusinessAndDateRange(
|
.listShiftRolesByBusinessAndDateRange(
|
||||||
businessId: businessId,
|
businessId: businessId,
|
||||||
start: _toTimestamp(start),
|
start: _service.toTimestamp(start),
|
||||||
end: _toTimestamp(end),
|
end: _service.toTimestamp(end),
|
||||||
)
|
)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
final fdc.QueryResult<
|
final fdc.QueryResult<dc.ListStaffsApplicationsByBusinessForDayData,
|
||||||
dc.ListStaffsApplicationsByBusinessForDayData,
|
dc.ListStaffsApplicationsByBusinessForDayVariables> applicationsResult =
|
||||||
dc.ListStaffsApplicationsByBusinessForDayVariables> applicationsResult =
|
await _service.connector
|
||||||
await _dataConnect
|
|
||||||
.listStaffsApplicationsByBusinessForDay(
|
.listStaffsApplicationsByBusinessForDay(
|
||||||
businessId: businessId,
|
businessId: businessId,
|
||||||
dayStart: _toTimestamp(start),
|
dayStart: _service.toTimestamp(start),
|
||||||
dayEnd: _toTimestamp(end),
|
dayEnd: _service.toTimestamp(end),
|
||||||
)
|
)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
||||||
@@ -61,18 +53,7 @@ class CoverageRepositoryImpl implements CoverageRepository {
|
|||||||
applicationsResult.data.applications,
|
applicationsResult.data.applications,
|
||||||
date,
|
date,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
});
|
||||||
final String error = e.toString().toLowerCase();
|
|
||||||
if (error.contains('network') ||
|
|
||||||
error.contains('connection') ||
|
|
||||||
error.contains('unavailable') ||
|
|
||||||
error.contains('offline') ||
|
|
||||||
error.contains('socket') ||
|
|
||||||
error.contains('failed host lookup')) {
|
|
||||||
throw NetworkException(technicalMessage: 'Coverage fetch failed: $e');
|
|
||||||
}
|
|
||||||
throw ServerException(technicalMessage: 'Coverage fetch failed: $e');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches coverage statistics for a specific date.
|
/// Fetches coverage statistics for a specific date.
|
||||||
@@ -110,14 +91,6 @@ class CoverageRepositoryImpl implements CoverageRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fdc.Timestamp _toTimestamp(DateTime dateTime) {
|
|
||||||
final DateTime utc = dateTime.toUtc();
|
|
||||||
final int seconds = utc.millisecondsSinceEpoch ~/ 1000;
|
|
||||||
final int nanoseconds =
|
|
||||||
(utc.millisecondsSinceEpoch % 1000) * 1000000;
|
|
||||||
return fdc.Timestamp(nanoseconds, seconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<CoverageShift> _mapCoverageShifts(
|
List<CoverageShift> _mapCoverageShifts(
|
||||||
List<dc.ListShiftRolesByBusinessAndDateRangeShiftRoles> shiftRoles,
|
List<dc.ListShiftRolesByBusinessAndDateRangeShiftRoles> shiftRoles,
|
||||||
List<dc.ListStaffsApplicationsByBusinessForDayApplications> applications,
|
List<dc.ListStaffsApplicationsByBusinessForDayApplications> applications,
|
||||||
@@ -151,10 +124,12 @@ class CoverageRepositoryImpl implements CoverageRepository {
|
|||||||
shiftId: app.shiftId,
|
shiftId: app.shiftId,
|
||||||
roleId: app.roleId,
|
roleId: app.roleId,
|
||||||
title: app.shiftRole.role.name,
|
title: app.shiftRole.role.name,
|
||||||
location: app.shiftRole.shift.location ?? '',
|
location: app.shiftRole.shift.location ??
|
||||||
startTime: '00:00',
|
app.shiftRole.shift.locationAddress ??
|
||||||
workersNeeded: 0,
|
'',
|
||||||
date: date,
|
startTime: _formatTime(app.shiftRole.startTime) ?? '00:00',
|
||||||
|
workersNeeded: app.shiftRole.count,
|
||||||
|
date: app.shiftRole.shift.date?.toDateTime() ?? date,
|
||||||
workers: <CoverageWorker>[],
|
workers: <CoverageWorker>[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'package:flutter/widgets.dart';
|
|||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
|
||||||
import 'data/repositories_impl/client_create_order_repository_impl.dart';
|
import 'data/repositories_impl/client_create_order_repository_impl.dart';
|
||||||
import 'domain/repositories/client_create_order_repository_interface.dart';
|
import 'domain/repositories/client_create_order_repository_interface.dart';
|
||||||
import 'domain/usecases/create_one_time_order_usecase.dart';
|
import 'domain/usecases/create_one_time_order_usecase.dart';
|
||||||
@@ -29,12 +28,7 @@ class ClientCreateOrderModule extends Module {
|
|||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
// Repositories
|
// Repositories
|
||||||
i.addLazySingleton<ClientCreateOrderRepositoryInterface>(
|
i.addLazySingleton<ClientCreateOrderRepositoryInterface>(ClientCreateOrderRepositoryImpl.new);
|
||||||
() => ClientCreateOrderRepositoryImpl(
|
|
||||||
firebaseAuth: firebase.FirebaseAuth.instance,
|
|
||||||
dataConnect: ExampleConnector.instance,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// UseCases
|
// UseCases
|
||||||
i.addLazySingleton(GetOrderTypesUseCase.new);
|
i.addLazySingleton(GetOrderTypesUseCase.new);
|
||||||
@@ -44,12 +38,7 @@ class ClientCreateOrderModule extends Module {
|
|||||||
// BLoCs
|
// BLoCs
|
||||||
i.add<ClientCreateOrderBloc>(ClientCreateOrderBloc.new);
|
i.add<ClientCreateOrderBloc>(ClientCreateOrderBloc.new);
|
||||||
i.add<RapidOrderBloc>(RapidOrderBloc.new);
|
i.add<RapidOrderBloc>(RapidOrderBloc.new);
|
||||||
i.add<OneTimeOrderBloc>(
|
i.add<OneTimeOrderBloc>(OneTimeOrderBloc.new);
|
||||||
() => OneTimeOrderBloc(
|
|
||||||
i.get<CreateOneTimeOrderUseCase>(),
|
|
||||||
ExampleConnector.instance,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
|
||||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||||
@@ -7,27 +6,21 @@ import '../../domain/repositories/client_create_order_repository_interface.dart'
|
|||||||
|
|
||||||
/// Implementation of [ClientCreateOrderRepositoryInterface].
|
/// Implementation of [ClientCreateOrderRepositoryInterface].
|
||||||
///
|
///
|
||||||
/// This implementation coordinates data access for order creation by delegating
|
/// This implementation coordinates data access for order creation by [DataConnectService] from the shared
|
||||||
/// to the [OrderRepositoryMock] and [ExampleConnector] from the shared
|
|
||||||
/// Data Connect package.
|
/// Data Connect package.
|
||||||
///
|
///
|
||||||
/// It follows the KROW Clean Architecture by keeping the data layer focused
|
/// It follows the KROW Clean Architecture by keeping the data layer focused
|
||||||
/// on delegation and data mapping, without business logic.
|
/// on delegation and data mapping, without business logic.
|
||||||
class ClientCreateOrderRepositoryImpl
|
class ClientCreateOrderRepositoryImpl implements ClientCreateOrderRepositoryInterface {
|
||||||
with dc.DataErrorHandler
|
|
||||||
implements ClientCreateOrderRepositoryInterface {
|
|
||||||
ClientCreateOrderRepositoryImpl({
|
ClientCreateOrderRepositoryImpl({
|
||||||
required firebase.FirebaseAuth firebaseAuth,
|
required dc.DataConnectService service,
|
||||||
required dc.ExampleConnector dataConnect,
|
}) : _service = service;
|
||||||
}) : _firebaseAuth = firebaseAuth,
|
|
||||||
_dataConnect = dataConnect;
|
|
||||||
|
|
||||||
final firebase.FirebaseAuth _firebaseAuth;
|
final dc.DataConnectService _service;
|
||||||
final dc.ExampleConnector _dataConnect;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<domain.OrderType>> getOrderTypes() {
|
Future<List<domain.OrderType>> getOrderTypes() {
|
||||||
return Future.value(const <domain.OrderType>[
|
return Future<List<domain.OrderType>>.value(const <domain.OrderType>[
|
||||||
domain.OrderType(
|
domain.OrderType(
|
||||||
id: 'one-time',
|
id: 'one-time',
|
||||||
titleKey: 'client_create_order.types.one_time',
|
titleKey: 'client_create_order.types.one_time',
|
||||||
@@ -55,100 +48,95 @@ class ClientCreateOrderRepositoryImpl
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> createOneTimeOrder(domain.OneTimeOrder order) async {
|
Future<void> createOneTimeOrder(domain.OneTimeOrder order) async {
|
||||||
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
|
return _service.run(() async {
|
||||||
if (businessId == null || businessId.isEmpty) {
|
final String businessId = await _service.getBusinessId();
|
||||||
await _firebaseAuth.signOut();
|
final String? vendorId = order.vendorId;
|
||||||
throw Exception('Business is missing. Please sign in again.');
|
if (vendorId == null || vendorId.isEmpty) {
|
||||||
}
|
throw Exception('Vendor is missing.');
|
||||||
final String? vendorId = order.vendorId;
|
}
|
||||||
if (vendorId == null || vendorId.isEmpty) {
|
final domain.OneTimeOrderHubDetails? hub = order.hub;
|
||||||
throw Exception('Vendor is missing.');
|
if (hub == null || hub.id.isEmpty) {
|
||||||
}
|
throw Exception('Hub is missing.');
|
||||||
final domain.OneTimeOrderHubDetails? hub = order.hub;
|
}
|
||||||
if (hub == null || hub.id.isEmpty) {
|
|
||||||
throw Exception('Hub is missing.');
|
|
||||||
}
|
|
||||||
|
|
||||||
final DateTime orderDateOnly = DateTime(
|
final DateTime orderDateOnly = DateTime(
|
||||||
order.date.year,
|
order.date.year,
|
||||||
order.date.month,
|
order.date.month,
|
||||||
order.date.day,
|
order.date.day,
|
||||||
);
|
);
|
||||||
final fdc.Timestamp orderTimestamp = _toTimestamp(orderDateOnly);
|
final fdc.Timestamp orderTimestamp = _service.toTimestamp(orderDateOnly);
|
||||||
final fdc.OperationResult<dc.CreateOrderData, dc.CreateOrderVariables>
|
final fdc.OperationResult<dc.CreateOrderData, dc.CreateOrderVariables> orderResult =
|
||||||
orderResult = await executeProtected(() => _dataConnect
|
await _service.connector
|
||||||
.createOrder(
|
.createOrder(
|
||||||
businessId: businessId,
|
businessId: businessId,
|
||||||
orderType: dc.OrderType.ONE_TIME,
|
orderType: dc.OrderType.ONE_TIME,
|
||||||
teamHubId: hub.id,
|
teamHubId: hub.id,
|
||||||
|
)
|
||||||
|
.vendorId(vendorId)
|
||||||
|
.eventName(order.eventName)
|
||||||
|
.status(dc.OrderStatus.POSTED)
|
||||||
|
.date(orderTimestamp)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
final String orderId = orderResult.data.order_insert.id;
|
||||||
|
|
||||||
|
final int workersNeeded = order.positions.fold<int>(
|
||||||
|
0,
|
||||||
|
(int sum, domain.OneTimeOrderPosition position) => sum + position.count,
|
||||||
|
);
|
||||||
|
final String shiftTitle = 'Shift 1 ${_formatDate(order.date)}';
|
||||||
|
final double shiftCost = _calculateShiftCost(order);
|
||||||
|
|
||||||
|
final fdc.OperationResult<dc.CreateShiftData, dc.CreateShiftVariables> shiftResult =
|
||||||
|
await _service.connector
|
||||||
|
.createShift(title: shiftTitle, orderId: orderId)
|
||||||
|
.date(orderTimestamp)
|
||||||
|
.location(hub.name)
|
||||||
|
.locationAddress(hub.address)
|
||||||
|
.latitude(hub.latitude)
|
||||||
|
.longitude(hub.longitude)
|
||||||
|
.placeId(hub.placeId)
|
||||||
|
.city(hub.city)
|
||||||
|
.state(hub.state)
|
||||||
|
.street(hub.street)
|
||||||
|
.country(hub.country)
|
||||||
|
.status(dc.ShiftStatus.PENDING)
|
||||||
|
.workersNeeded(workersNeeded)
|
||||||
|
.filled(0)
|
||||||
|
.durationDays(1)
|
||||||
|
.cost(shiftCost)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
final String shiftId = shiftResult.data.shift_insert.id;
|
||||||
|
|
||||||
|
for (final domain.OneTimeOrderPosition position in order.positions) {
|
||||||
|
final DateTime start = _parseTime(order.date, position.startTime);
|
||||||
|
final DateTime end = _parseTime(order.date, position.endTime);
|
||||||
|
final DateTime normalizedEnd = end.isBefore(start) ? end.add(const Duration(days: 1)) : end;
|
||||||
|
final double hours = normalizedEnd.difference(start).inMinutes / 60.0;
|
||||||
|
final double rate = order.roleRates[position.role] ?? 0;
|
||||||
|
final double totalValue = rate * hours * position.count;
|
||||||
|
|
||||||
|
await _service.connector
|
||||||
|
.createShiftRole(
|
||||||
|
shiftId: shiftId,
|
||||||
|
roleId: position.role,
|
||||||
|
count: position.count,
|
||||||
)
|
)
|
||||||
.vendorId(vendorId)
|
.startTime(_service.toTimestamp(start))
|
||||||
.eventName(order.eventName)
|
.endTime(_service.toTimestamp(normalizedEnd))
|
||||||
.status(dc.OrderStatus.POSTED)
|
.hours(hours)
|
||||||
.date(orderTimestamp)
|
.breakType(_breakDurationFromValue(position.lunchBreak))
|
||||||
.execute());
|
.isBreakPaid(_isBreakPaid(position.lunchBreak))
|
||||||
|
.totalValue(totalValue)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
|
||||||
final String orderId = orderResult.data.order_insert.id;
|
await _service.connector
|
||||||
|
.updateOrder(id: orderId, teamHubId: hub.id)
|
||||||
final int workersNeeded = order.positions.fold<int>(
|
.shifts(fdc.AnyValue(<String>[shiftId]))
|
||||||
0,
|
.execute();
|
||||||
(int sum, domain.OneTimeOrderPosition position) => sum + position.count,
|
});
|
||||||
);
|
|
||||||
final String shiftTitle = 'Shift 1 ${_formatDate(order.date)}';
|
|
||||||
final double shiftCost = _calculateShiftCost(order);
|
|
||||||
|
|
||||||
final fdc.OperationResult<dc.CreateShiftData, dc.CreateShiftVariables>
|
|
||||||
shiftResult = await executeProtected(() => _dataConnect
|
|
||||||
.createShift(title: shiftTitle, orderId: orderId)
|
|
||||||
.date(orderTimestamp)
|
|
||||||
.location(hub.name)
|
|
||||||
.locationAddress(hub.address)
|
|
||||||
.latitude(hub.latitude)
|
|
||||||
.longitude(hub.longitude)
|
|
||||||
.placeId(hub.placeId)
|
|
||||||
.city(hub.city)
|
|
||||||
.state(hub.state)
|
|
||||||
.street(hub.street)
|
|
||||||
.country(hub.country)
|
|
||||||
.status(dc.ShiftStatus.PENDING)
|
|
||||||
.workersNeeded(workersNeeded)
|
|
||||||
.filled(0)
|
|
||||||
.durationDays(1)
|
|
||||||
.cost(shiftCost)
|
|
||||||
.execute());
|
|
||||||
|
|
||||||
final String shiftId = shiftResult.data.shift_insert.id;
|
|
||||||
|
|
||||||
for (final domain.OneTimeOrderPosition position in order.positions) {
|
|
||||||
final DateTime start = _parseTime(order.date, position.startTime);
|
|
||||||
final DateTime end = _parseTime(order.date, position.endTime);
|
|
||||||
final DateTime normalizedEnd =
|
|
||||||
end.isBefore(start) ? end.add(const Duration(days: 1)) : end;
|
|
||||||
final double hours = normalizedEnd.difference(start).inMinutes / 60.0;
|
|
||||||
final double rate = order.roleRates[position.role] ?? 0;
|
|
||||||
final double totalValue = rate * hours * position.count;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
await executeProtected(() => _dataConnect
|
|
||||||
.createShiftRole(
|
|
||||||
shiftId: shiftId,
|
|
||||||
roleId: position.role,
|
|
||||||
count: position.count,
|
|
||||||
)
|
|
||||||
.startTime(_toTimestamp(start))
|
|
||||||
.endTime(_toTimestamp(normalizedEnd))
|
|
||||||
.hours(hours)
|
|
||||||
.breakType(_breakDurationFromValue(position.lunchBreak))
|
|
||||||
.isBreakPaid(_isBreakPaid(position.lunchBreak))
|
|
||||||
.totalValue(totalValue)
|
|
||||||
.execute());
|
|
||||||
}
|
|
||||||
|
|
||||||
await executeProtected(() => _dataConnect
|
|
||||||
.updateOrder(id: orderId, teamHubId: hub.id)
|
|
||||||
.shifts(fdc.AnyValue(<String>[shiftId]))
|
|
||||||
.execute());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -213,13 +201,6 @@ class ClientCreateOrderRepositoryImpl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fdc.Timestamp _toTimestamp(DateTime dateTime) {
|
|
||||||
final DateTime utc = dateTime.toUtc();
|
|
||||||
final int seconds = utc.millisecondsSinceEpoch ~/ 1000;
|
|
||||||
final int nanoseconds = (utc.microsecondsSinceEpoch % 1000000) * 1000;
|
|
||||||
return fdc.Timestamp(nanoseconds, seconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _formatDate(DateTime dateTime) {
|
String _formatDate(DateTime dateTime) {
|
||||||
final String year = dateTime.year.toString().padLeft(4, '0');
|
final String year = dateTime.year.toString().padLeft(4, '0');
|
||||||
final String month = dateTime.month.toString().padLeft(2, '0');
|
final String month = dateTime.month.toString().padLeft(2, '0');
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import 'one_time_order_state.dart';
|
|||||||
/// BLoC for managing the multi-step one-time order creation form.
|
/// BLoC for managing the multi-step one-time order creation form.
|
||||||
class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
||||||
with BlocErrorHandler<OneTimeOrderState>, SafeBloc<OneTimeOrderEvent, OneTimeOrderState> {
|
with BlocErrorHandler<OneTimeOrderState>, SafeBloc<OneTimeOrderEvent, OneTimeOrderState> {
|
||||||
OneTimeOrderBloc(this._createOneTimeOrderUseCase, this._dataConnect)
|
OneTimeOrderBloc(this._createOneTimeOrderUseCase, this._service)
|
||||||
: super(OneTimeOrderState.initial()) {
|
: super(OneTimeOrderState.initial()) {
|
||||||
on<OneTimeOrderVendorsLoaded>(_onVendorsLoaded);
|
on<OneTimeOrderVendorsLoaded>(_onVendorsLoaded);
|
||||||
on<OneTimeOrderVendorChanged>(_onVendorChanged);
|
on<OneTimeOrderVendorChanged>(_onVendorChanged);
|
||||||
@@ -28,13 +28,13 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
|||||||
_loadHubs();
|
_loadHubs();
|
||||||
}
|
}
|
||||||
final CreateOneTimeOrderUseCase _createOneTimeOrderUseCase;
|
final CreateOneTimeOrderUseCase _createOneTimeOrderUseCase;
|
||||||
final dc.ExampleConnector _dataConnect;
|
final dc.DataConnectService _service;
|
||||||
|
|
||||||
Future<void> _loadVendors() async {
|
Future<void> _loadVendors() async {
|
||||||
final List<Vendor>? vendors = await handleErrorWithResult(
|
final List<Vendor>? vendors = await handleErrorWithResult(
|
||||||
action: () async {
|
action: () async {
|
||||||
final QueryResult<dc.ListVendorsData, void> result =
|
final QueryResult<dc.ListVendorsData, void> result =
|
||||||
await _dataConnect.listVendors().execute();
|
await _service.connector.listVendors().execute();
|
||||||
return result.data.vendors
|
return result.data.vendors
|
||||||
.map(
|
.map(
|
||||||
(dc.ListVendorsVendors vendor) => Vendor(
|
(dc.ListVendorsVendors vendor) => Vendor(
|
||||||
@@ -57,7 +57,7 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
|||||||
final List<OneTimeOrderRoleOption>? roles = await handleErrorWithResult(
|
final List<OneTimeOrderRoleOption>? roles = await handleErrorWithResult(
|
||||||
action: () async {
|
action: () async {
|
||||||
final QueryResult<dc.ListRolesByVendorIdData, dc.ListRolesByVendorIdVariables>
|
final QueryResult<dc.ListRolesByVendorIdData, dc.ListRolesByVendorIdVariables>
|
||||||
result = await _dataConnect.listRolesByVendorId(vendorId: vendorId).execute();
|
result = await _service.connector.listRolesByVendorId(vendorId: vendorId).execute();
|
||||||
return result.data.roles
|
return result.data.roles
|
||||||
.map(
|
.map(
|
||||||
(dc.ListRolesByVendorIdRoles role) => OneTimeOrderRoleOption(
|
(dc.ListRolesByVendorIdRoles role) => OneTimeOrderRoleOption(
|
||||||
@@ -79,13 +79,9 @@ class OneTimeOrderBloc extends Bloc<OneTimeOrderEvent, OneTimeOrderState>
|
|||||||
Future<void> _loadHubs() async {
|
Future<void> _loadHubs() async {
|
||||||
final List<OneTimeOrderHubOption>? hubs = await handleErrorWithResult(
|
final List<OneTimeOrderHubOption>? hubs = await handleErrorWithResult(
|
||||||
action: () async {
|
action: () async {
|
||||||
final String? businessId =
|
final String businessId = await _service.getBusinessId();
|
||||||
dc.ClientSessionStore.instance.session?.business?.id;
|
|
||||||
if (businessId == null || businessId.isEmpty) {
|
|
||||||
return <OneTimeOrderHubOption>[];
|
|
||||||
}
|
|
||||||
final QueryResult<dc.ListTeamHubsByOwnerIdData, dc.ListTeamHubsByOwnerIdVariables>
|
final QueryResult<dc.ListTeamHubsByOwnerIdData, dc.ListTeamHubsByOwnerIdVariables>
|
||||||
result = await _dataConnect
|
result = await _service.connector
|
||||||
.listTeamHubsByOwnerId(ownerId: businessId)
|
.listTeamHubsByOwnerId(ownerId: businessId)
|
||||||
.execute();
|
.execute();
|
||||||
return result.data.teamHubs
|
return result.data.teamHubs
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ class OneTimeOrderSuccessView extends StatelessWidget {
|
|||||||
color: UiColors.accent,
|
color: UiColors.accent,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|
||||||
child: const Center(
|
child: const Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
UiIcons.check,
|
UiIcons.check,
|
||||||
|
|||||||
@@ -72,6 +72,12 @@ class _RapidOrderFormState extends State<_RapidOrderForm> {
|
|||||||
TextPosition(offset: _messageController.text.length),
|
TextPosition(offset: _messageController.text.length),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (state is RapidOrderFailure) {
|
||||||
|
UiSnackbar.show(
|
||||||
|
context,
|
||||||
|
message: translateErrorKey(state.error),
|
||||||
|
type: UiSnackbarType.error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
|
|||||||
@@ -23,11 +23,7 @@ class ClientHomeModule extends Module {
|
|||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
// Repositories
|
// Repositories
|
||||||
i.addLazySingleton<HomeRepositoryInterface>(
|
i.addLazySingleton<HomeRepositoryInterface>(HomeRepositoryImpl.new);
|
||||||
() => HomeRepositoryImpl(
|
|
||||||
ExampleConnector.instance,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// UseCases
|
// UseCases
|
||||||
i.addLazySingleton(GetDashboardDataUseCase.new);
|
i.addLazySingleton(GetDashboardDataUseCase.new);
|
||||||
@@ -35,13 +31,7 @@ class ClientHomeModule extends Module {
|
|||||||
i.addLazySingleton(GetUserSessionDataUseCase.new);
|
i.addLazySingleton(GetUserSessionDataUseCase.new);
|
||||||
|
|
||||||
// BLoCs
|
// BLoCs
|
||||||
i.add<ClientHomeBloc>(
|
i.add<ClientHomeBloc>(ClientHomeBloc.new);
|
||||||
() => ClientHomeBloc(
|
|
||||||
getDashboardDataUseCase: i.get<GetDashboardDataUseCase>(),
|
|
||||||
getRecentReordersUseCase: i.get<GetRecentReordersUseCase>(),
|
|
||||||
getUserSessionDataUseCase: i.get<GetUserSessionDataUseCase>(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -8,43 +8,47 @@ import '../../domain/repositories/home_repository_interface.dart';
|
|||||||
/// This implementation resides in the data layer and acts as a bridge between the
|
/// This implementation resides in the data layer and acts as a bridge between the
|
||||||
/// domain layer and the data source (in this case, a mock from data_connect).
|
/// domain layer and the data source (in this case, a mock from data_connect).
|
||||||
class HomeRepositoryImpl implements HomeRepositoryInterface {
|
class HomeRepositoryImpl implements HomeRepositoryInterface {
|
||||||
|
|
||||||
/// Creates a [HomeRepositoryImpl].
|
/// Creates a [HomeRepositoryImpl].
|
||||||
HomeRepositoryImpl(this._dataConnect);
|
HomeRepositoryImpl(this._service);
|
||||||
final dc.ExampleConnector _dataConnect;
|
final dc.DataConnectService _service;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<HomeDashboardData> getDashboardData() async {
|
Future<HomeDashboardData> getDashboardData() async {
|
||||||
try {
|
return _service.run(() async {
|
||||||
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
|
final String businessId = await _service.getBusinessId();
|
||||||
if (businessId == null || businessId.isEmpty) {
|
|
||||||
return const HomeDashboardData(
|
|
||||||
weeklySpending: 0,
|
|
||||||
next7DaysSpending: 0,
|
|
||||||
weeklyShifts: 0,
|
|
||||||
next7DaysScheduled: 0,
|
|
||||||
totalNeeded: 0,
|
|
||||||
totalFilled: 0,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final DateTime now = DateTime.now();
|
final DateTime now = DateTime.now();
|
||||||
final int daysFromMonday = now.weekday - DateTime.monday;
|
final int daysFromMonday = now.weekday - DateTime.monday;
|
||||||
final DateTime monday =
|
final DateTime monday = DateTime(
|
||||||
DateTime(now.year, now.month, now.day).subtract(Duration(days: daysFromMonday));
|
now.year,
|
||||||
final DateTime weekRangeStart = DateTime(monday.year, monday.month, monday.day);
|
now.month,
|
||||||
final DateTime weekRangeEnd =
|
now.day,
|
||||||
DateTime(monday.year, monday.month, monday.day + 13, 23, 59, 59, 999);
|
).subtract(Duration(days: daysFromMonday));
|
||||||
|
final DateTime weekRangeStart = DateTime(
|
||||||
|
monday.year,
|
||||||
|
monday.month,
|
||||||
|
monday.day,
|
||||||
|
);
|
||||||
|
final DateTime weekRangeEnd = DateTime(
|
||||||
|
monday.year,
|
||||||
|
monday.month,
|
||||||
|
monday.day + 13,
|
||||||
|
23,
|
||||||
|
59,
|
||||||
|
59,
|
||||||
|
999,
|
||||||
|
);
|
||||||
final fdc.QueryResult<
|
final fdc.QueryResult<
|
||||||
dc.GetCompletedShiftsByBusinessIdData,
|
dc.GetCompletedShiftsByBusinessIdData,
|
||||||
dc.GetCompletedShiftsByBusinessIdVariables> completedResult =
|
dc.GetCompletedShiftsByBusinessIdVariables
|
||||||
await _dataConnect
|
>
|
||||||
.getCompletedShiftsByBusinessId(
|
completedResult = await _service.connector
|
||||||
businessId: businessId,
|
.getCompletedShiftsByBusinessId(
|
||||||
dateFrom: _toTimestamp(weekRangeStart),
|
businessId: businessId,
|
||||||
dateTo: _toTimestamp(weekRangeEnd),
|
dateFrom: _service.toTimestamp(weekRangeStart),
|
||||||
)
|
dateTo: _service.toTimestamp(weekRangeEnd),
|
||||||
.execute();
|
)
|
||||||
|
.execute();
|
||||||
|
|
||||||
double weeklySpending = 0.0;
|
double weeklySpending = 0.0;
|
||||||
double next7DaysSpending = 0.0;
|
double next7DaysSpending = 0.0;
|
||||||
@@ -71,18 +75,27 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final DateTime start = DateTime(now.year, now.month, now.day);
|
final DateTime start = DateTime(now.year, now.month, now.day);
|
||||||
final DateTime end = DateTime(now.year, now.month, now.day, 23, 59, 59, 999);
|
final DateTime end = DateTime(
|
||||||
|
now.year,
|
||||||
|
now.month,
|
||||||
|
now.day,
|
||||||
|
23,
|
||||||
|
59,
|
||||||
|
59,
|
||||||
|
999,
|
||||||
|
);
|
||||||
|
|
||||||
final fdc.QueryResult<
|
final fdc.QueryResult<
|
||||||
dc.ListShiftRolesByBusinessAndDateRangeData,
|
dc.ListShiftRolesByBusinessAndDateRangeData,
|
||||||
dc.ListShiftRolesByBusinessAndDateRangeVariables> result =
|
dc.ListShiftRolesByBusinessAndDateRangeVariables
|
||||||
await _dataConnect
|
>
|
||||||
.listShiftRolesByBusinessAndDateRange(
|
result = await _service.connector
|
||||||
businessId: businessId,
|
.listShiftRolesByBusinessAndDateRange(
|
||||||
start: _toTimestamp(start),
|
businessId: businessId,
|
||||||
end: _toTimestamp(end),
|
start: _service.toTimestamp(start),
|
||||||
)
|
end: _service.toTimestamp(end),
|
||||||
.execute();
|
)
|
||||||
|
.execute();
|
||||||
|
|
||||||
int totalNeeded = 0;
|
int totalNeeded = 0;
|
||||||
int totalFilled = 0;
|
int totalFilled = 0;
|
||||||
@@ -100,59 +113,80 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
|
|||||||
totalNeeded: totalNeeded,
|
totalNeeded: totalNeeded,
|
||||||
totalFilled: totalFilled,
|
totalFilled: totalFilled,
|
||||||
);
|
);
|
||||||
} catch (e) {
|
});
|
||||||
final String error = e.toString().toLowerCase();
|
|
||||||
if (error.contains('network') ||
|
|
||||||
error.contains('connection') ||
|
|
||||||
error.contains('unavailable') ||
|
|
||||||
error.contains('offline') ||
|
|
||||||
error.contains('socket') ||
|
|
||||||
error.contains('failed host lookup')) {
|
|
||||||
throw NetworkException(technicalMessage: 'Home dashboard fetch failed: $e');
|
|
||||||
}
|
|
||||||
throw ServerException(technicalMessage: 'Home dashboard fetch failed: $e');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
UserSessionData getUserSessionData() {
|
Future<UserSessionData> getUserSessionData() async {
|
||||||
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
|
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
|
||||||
return UserSessionData(
|
final dc.ClientBusinessSession? business = session?.business;
|
||||||
businessName: session?.business?.businessName ?? '',
|
|
||||||
photoUrl: null, // Business photo isn't currently in session
|
// If session data is available, return it immediately
|
||||||
);
|
if (business != null) {
|
||||||
|
return UserSessionData(
|
||||||
|
businessName: business.businessName,
|
||||||
|
photoUrl: business.companyLogoUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await _service.run(() async {
|
||||||
|
// If session is not initialized, attempt to fetch business data to populate session
|
||||||
|
final String businessId = await _service.getBusinessId();
|
||||||
|
final fdc.QueryResult<dc.GetBusinessByIdData, dc.GetBusinessByIdVariables>
|
||||||
|
businessResult = await _service.connector
|
||||||
|
.getBusinessById(id: businessId)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
if (businessResult.data.business == null) {
|
||||||
|
throw Exception('Business data not found for ID: $businessId');
|
||||||
|
}
|
||||||
|
|
||||||
|
final dc.ClientSession updatedSession = dc.ClientSession(
|
||||||
|
business: dc.ClientBusinessSession(
|
||||||
|
id: businessResult.data.business!.id,
|
||||||
|
businessName: businessResult.data.business?.businessName ?? '',
|
||||||
|
email: businessResult.data.business?.email ?? '',
|
||||||
|
city: businessResult.data.business?.city ?? '',
|
||||||
|
contactName: businessResult.data.business?.contactName ?? '',
|
||||||
|
companyLogoUrl: businessResult.data.business?.companyLogoUrl,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
dc.ClientSessionStore.instance.setSession(updatedSession);
|
||||||
|
|
||||||
|
return UserSessionData(
|
||||||
|
businessName: businessResult.data.business!.businessName,
|
||||||
|
photoUrl: businessResult.data.business!.companyLogoUrl,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<ReorderItem>> getRecentReorders() async {
|
Future<List<ReorderItem>> getRecentReorders() async {
|
||||||
try {
|
return _service.run(() async {
|
||||||
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
|
final String businessId = await _service.getBusinessId();
|
||||||
if (businessId == null || businessId.isEmpty) {
|
|
||||||
return const <ReorderItem>[];
|
|
||||||
}
|
|
||||||
|
|
||||||
final DateTime now = DateTime.now();
|
final DateTime now = DateTime.now();
|
||||||
final DateTime start = now.subtract(const Duration(days: 30));
|
final DateTime start = now.subtract(const Duration(days: 30));
|
||||||
final fdc.Timestamp startTimestamp = _toTimestamp(start);
|
final fdc.Timestamp startTimestamp = _service.toTimestamp(start);
|
||||||
final fdc.Timestamp endTimestamp = _toTimestamp(now);
|
final fdc.Timestamp endTimestamp = _service.toTimestamp(now);
|
||||||
|
|
||||||
final fdc.QueryResult<
|
final fdc.QueryResult<
|
||||||
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersData,
|
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersData,
|
||||||
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersVariables> result =
|
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersVariables
|
||||||
await _dataConnect.listShiftRolesByBusinessDateRangeCompletedOrders(
|
>
|
||||||
|
result = await _service.connector
|
||||||
|
.listShiftRolesByBusinessDateRangeCompletedOrders(
|
||||||
businessId: businessId,
|
businessId: businessId,
|
||||||
start: startTimestamp,
|
start: startTimestamp,
|
||||||
end: endTimestamp,
|
end: endTimestamp,
|
||||||
).execute();
|
)
|
||||||
|
.execute();
|
||||||
|
|
||||||
return result.data.shiftRoles.map((
|
return result.data.shiftRoles.map((
|
||||||
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersShiftRoles shiftRole,
|
dc.ListShiftRolesByBusinessDateRangeCompletedOrdersShiftRoles shiftRole,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
final String location =
|
final String location =
|
||||||
shiftRole.shift.location ??
|
shiftRole.shift.location ?? shiftRole.shift.locationAddress ?? '';
|
||||||
shiftRole.shift.locationAddress ??
|
|
||||||
'';
|
|
||||||
final String type = shiftRole.shift.order.orderType.stringValue;
|
final String type = shiftRole.shift.order.orderType.stringValue;
|
||||||
return ReorderItem(
|
return ReorderItem(
|
||||||
orderId: shiftRole.shift.order.id,
|
orderId: shiftRole.shift.order.id,
|
||||||
@@ -164,25 +198,6 @@ class HomeRepositoryImpl implements HomeRepositoryInterface {
|
|||||||
type: type,
|
type: type,
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
} catch (e) {
|
});
|
||||||
final String error = e.toString().toLowerCase();
|
|
||||||
if (error.contains('network') ||
|
|
||||||
error.contains('connection') ||
|
|
||||||
error.contains('unavailable') ||
|
|
||||||
error.contains('offline') ||
|
|
||||||
error.contains('socket') ||
|
|
||||||
error.contains('failed host lookup')) {
|
|
||||||
throw NetworkException(technicalMessage: 'Home reorders fetch failed: $e');
|
|
||||||
}
|
|
||||||
throw ServerException(technicalMessage: 'Home reorders fetch failed: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fdc.Timestamp _toTimestamp(DateTime date) {
|
|
||||||
final DateTime utc = date.toUtc();
|
|
||||||
final int millis = utc.millisecondsSinceEpoch;
|
|
||||||
final int seconds = millis ~/ 1000;
|
|
||||||
final int nanos = (millis % 1000) * 1000000;
|
|
||||||
return fdc.Timestamp(nanos, seconds);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ abstract interface class HomeRepositoryInterface {
|
|||||||
Future<HomeDashboardData> getDashboardData();
|
Future<HomeDashboardData> getDashboardData();
|
||||||
|
|
||||||
/// Fetches the user's session data (business name and photo).
|
/// Fetches the user's session data (business name and photo).
|
||||||
UserSessionData getUserSessionData();
|
Future<UserSessionData> getUserSessionData();
|
||||||
|
|
||||||
/// Fetches recently completed shift roles for reorder suggestions.
|
/// Fetches recently completed shift roles for reorder suggestions.
|
||||||
Future<List<ReorderItem>> getRecentReorders();
|
Future<List<ReorderItem>> getRecentReorders();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class GetUserSessionDataUseCase {
|
|||||||
final HomeRepositoryInterface _repository;
|
final HomeRepositoryInterface _repository;
|
||||||
|
|
||||||
/// Executes the use case to get session data.
|
/// Executes the use case to get session data.
|
||||||
UserSessionData call() {
|
Future<UserSessionData> call() {
|
||||||
return _repository.getUserSessionData();
|
return _repository.getUserSessionData();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class ClientHomeBloc extends Bloc<ClientHomeEvent, ClientHomeState>
|
|||||||
emit: emit,
|
emit: emit,
|
||||||
action: () async {
|
action: () async {
|
||||||
// Get session data
|
// Get session data
|
||||||
final UserSessionData sessionData = _getUserSessionDataUseCase();
|
final UserSessionData sessionData = await _getUserSessionDataUseCase();
|
||||||
|
|
||||||
// Get dashboard data
|
// Get dashboard data
|
||||||
final HomeDashboardData data = await _getDashboardDataUseCase();
|
final HomeDashboardData data = await _getDashboardDataUseCase();
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
/// A widget that displays quick actions for the client.
|
/// A widget that displays quick actions for the client.
|
||||||
class ActionsWidget extends StatelessWidget {
|
class ActionsWidget extends StatelessWidget {
|
||||||
|
|
||||||
/// Creates an [ActionsWidget].
|
/// Creates an [ActionsWidget].
|
||||||
const ActionsWidget({
|
const ActionsWidget({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -12,6 +11,7 @@ class ActionsWidget extends StatelessWidget {
|
|||||||
required this.onCreateOrderPressed,
|
required this.onCreateOrderPressed,
|
||||||
this.subtitle,
|
this.subtitle,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Callback when RAPID is pressed.
|
/// Callback when RAPID is pressed.
|
||||||
final VoidCallback onRapidPressed;
|
final VoidCallback onRapidPressed;
|
||||||
|
|
||||||
@@ -26,12 +26,9 @@ class ActionsWidget extends StatelessWidget {
|
|||||||
// Check if client_home exists in t
|
// Check if client_home exists in t
|
||||||
final TranslationsClientHomeActionsEn i18n = t.client_home.actions;
|
final TranslationsClientHomeActionsEn i18n = t.client_home.actions;
|
||||||
|
|
||||||
return Column(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
spacing: UiConstants.space4,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Row(
|
|
||||||
children: <Widget>[
|
|
||||||
/// TODO: FEATURE_NOT_YET_IMPLEMENTED
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _ActionCard(
|
child: _ActionCard(
|
||||||
title: i18n.rapid,
|
title: i18n.rapid,
|
||||||
@@ -46,7 +43,6 @@ class ActionsWidget extends StatelessWidget {
|
|||||||
onTap: onRapidPressed,
|
onTap: onRapidPressed,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// const SizedBox(width: UiConstants.space2),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _ActionCard(
|
child: _ActionCard(
|
||||||
title: i18n.create_order,
|
title: i18n.create_order,
|
||||||
@@ -62,14 +58,11 @@ class ActionsWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ActionCard extends StatelessWidget {
|
class _ActionCard extends StatelessWidget {
|
||||||
|
|
||||||
const _ActionCard({
|
const _ActionCard({
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.subtitle,
|
required this.subtitle,
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ class CoverageDashboard extends StatelessWidget {
|
|||||||
int totalConfirmed = 0;
|
int totalConfirmed = 0;
|
||||||
double todayCost = 0;
|
double todayCost = 0;
|
||||||
|
|
||||||
for (final s in shifts) {
|
for (final dynamic s in shifts) {
|
||||||
final int needed = s['workersNeeded'] as int? ?? 0;
|
final int needed = (s as Map<String, dynamic>)['workersNeeded'] as int? ?? 0;
|
||||||
final int confirmed = s['filled'] as int? ?? 0;
|
final int confirmed = s['filled'] as int? ?? 0;
|
||||||
final double rate = s['hourlyRate'] as double? ?? 0.0;
|
final double rate = s['hourlyRate'] as double? ?? 0.0;
|
||||||
final double hours = s['hours'] as double? ?? 0.0;
|
final double hours = s['hours'] as double? ?? 0.0;
|
||||||
@@ -39,10 +39,10 @@ class CoverageDashboard extends StatelessWidget {
|
|||||||
final int unfilledPositions = totalNeeded - totalConfirmed;
|
final int unfilledPositions = totalNeeded - totalConfirmed;
|
||||||
|
|
||||||
final int checkedInCount = applications
|
final int checkedInCount = applications
|
||||||
.where((a) => (a as Map)['checkInTime'] != null)
|
.where((dynamic a) => (a as Map<String, dynamic>)['checkInTime'] != null)
|
||||||
.length;
|
.length;
|
||||||
final int lateWorkersCount = applications
|
final int lateWorkersCount = applications
|
||||||
.where((a) => (a as Map)['status'] == 'LATE')
|
.where((dynamic a) => (a as Map<String, dynamic>)['status'] == 'LATE')
|
||||||
.length;
|
.length;
|
||||||
|
|
||||||
final bool isCoverageGood = coveragePercent >= 90;
|
final bool isCoverageGood = coveragePercent >= 90;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:core_localization/core_localization.dart';
|
||||||
import 'package:design_system/design_system.dart';
|
import 'package:design_system/design_system.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
@@ -47,7 +48,7 @@ class CoverageWidget extends StatelessWidget {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(
|
||||||
"TODAY'S COVERAGE",
|
t.client_home.dashboard.todays_coverage,
|
||||||
style: UiTypography.footnote1b.copyWith(
|
style: UiTypography.footnote1b.copyWith(
|
||||||
color: UiColors.textPrimary,
|
color: UiColors.textPrimary,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
@@ -64,8 +65,8 @@ class CoverageWidget extends StatelessWidget {
|
|||||||
color: backgroundColor,
|
color: backgroundColor,
|
||||||
borderRadius: UiConstants.radiusLg,
|
borderRadius: UiConstants.radiusLg,
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'$coveragePercent% Covered',
|
t.client_home.dashboard.percent_covered(percent: coveragePercent),
|
||||||
style: UiTypography.footnote2b.copyWith(color: textColor),
|
style: UiTypography.footnote2b.copyWith(color: textColor),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -81,7 +82,7 @@ class CoverageWidget extends StatelessWidget {
|
|||||||
child: _MetricCard(
|
child: _MetricCard(
|
||||||
icon: UiIcons.target,
|
icon: UiIcons.target,
|
||||||
iconColor: UiColors.primary,
|
iconColor: UiColors.primary,
|
||||||
label: 'Needed',
|
label: t.client_home.dashboard.metric_needed,
|
||||||
value: '$totalNeeded',
|
value: '$totalNeeded',
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -91,7 +92,7 @@ class CoverageWidget extends StatelessWidget {
|
|||||||
child: _MetricCard(
|
child: _MetricCard(
|
||||||
icon: UiIcons.success,
|
icon: UiIcons.success,
|
||||||
iconColor: UiColors.iconSuccess,
|
iconColor: UiColors.iconSuccess,
|
||||||
label: 'Filled',
|
label: t.client_home.dashboard.metric_filled,
|
||||||
value: '$totalConfirmed',
|
value: '$totalConfirmed',
|
||||||
valueColor: UiColors.textSuccess,
|
valueColor: UiColors.textSuccess,
|
||||||
),
|
),
|
||||||
@@ -101,7 +102,7 @@ class CoverageWidget extends StatelessWidget {
|
|||||||
child: _MetricCard(
|
child: _MetricCard(
|
||||||
icon: UiIcons.error,
|
icon: UiIcons.error,
|
||||||
iconColor: UiColors.iconError,
|
iconColor: UiColors.iconError,
|
||||||
label: 'Open',
|
label: t.client_home.dashboard.metric_open,
|
||||||
value: '${totalNeeded - totalConfirmed}',
|
value: '${totalNeeded - totalConfirmed}',
|
||||||
valueColor: UiColors.textError,
|
valueColor: UiColors.textError,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -65,6 +65,8 @@ class _ShiftOrderFormSheetState extends State<ShiftOrderFormSheet> {
|
|||||||
dc.ListTeamHubsByOwnerIdTeamHubs? _selectedHub;
|
dc.ListTeamHubsByOwnerIdTeamHubs? _selectedHub;
|
||||||
bool _showSuccess = false;
|
bool _showSuccess = false;
|
||||||
Map<String, dynamic>? _submitData;
|
Map<String, dynamic>? _submitData;
|
||||||
|
bool _isSubmitting = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -190,7 +192,25 @@ class _ShiftOrderFormSheetState extends State<ShiftOrderFormSheet> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleSubmit() async {
|
Future<void> _handleSubmit() async {
|
||||||
await _submitNewOrder();
|
if (_isSubmitting) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = true;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _submitNewOrder();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isSubmitting = false;
|
||||||
|
_errorMessage = 'Failed to create order. Please try again.';
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(_errorMessage!)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _submitNewOrder() async {
|
Future<void> _submitNewOrder() async {
|
||||||
@@ -296,6 +316,7 @@ class _ShiftOrderFormSheetState extends State<ShiftOrderFormSheet> {
|
|||||||
'date': _dateController.text,
|
'date': _dateController.text,
|
||||||
};
|
};
|
||||||
_showSuccess = true;
|
_showSuccess = true;
|
||||||
|
_isSubmitting = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -770,7 +791,7 @@ class _ShiftOrderFormSheetState extends State<ShiftOrderFormSheet> {
|
|||||||
|
|
||||||
UiButton.primary(
|
UiButton.primary(
|
||||||
text: widget.initialData != null ? 'Update Order' : 'Post Order',
|
text: widget.initialData != null ? 'Update Order' : 'Post Order',
|
||||||
onPressed: widget.isLoading ? null : _handleSubmit,
|
onPressed: (widget.isLoading || _isSubmitting) ? null : _handleSubmit,
|
||||||
),
|
),
|
||||||
SizedBox(height: MediaQuery.of(context).padding.bottom + UiConstants.space5),
|
SizedBox(height: MediaQuery.of(context).padding.bottom + UiConstants.space5),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class SpendingWidget extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(
|
||||||
'This Week',
|
t.client_home.dashboard.spending.this_week,
|
||||||
style: UiTypography.footnote2r.white.copyWith(
|
style: UiTypography.footnote2r.white.copyWith(
|
||||||
color: UiColors.white.withValues(alpha: 0.7),
|
color: UiColors.white.withValues(alpha: 0.7),
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
@@ -93,7 +93,7 @@ class SpendingWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'$weeklyShifts shifts',
|
t.client_home.dashboard.spending.shifts_count(count: weeklyShifts),
|
||||||
style: UiTypography.footnote2r.white.copyWith(
|
style: UiTypography.footnote2r.white.copyWith(
|
||||||
color: UiColors.white.withValues(alpha: 0.6),
|
color: UiColors.white.withValues(alpha: 0.6),
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
@@ -107,7 +107,7 @@ class SpendingWidget extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(
|
||||||
'Next 7 Days',
|
t.client_home.dashboard.spending.next_7_days,
|
||||||
style: UiTypography.footnote2r.white.copyWith(
|
style: UiTypography.footnote2r.white.copyWith(
|
||||||
color: UiColors.white.withValues(alpha: 0.7),
|
color: UiColors.white.withValues(alpha: 0.7),
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
@@ -122,7 +122,7 @@ class SpendingWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'$next7DaysScheduled scheduled',
|
t.client_home.dashboard.spending.scheduled_count(count: next7DaysScheduled),
|
||||||
style: UiTypography.footnote2r.white.copyWith(
|
style: UiTypography.footnote2r.white.copyWith(
|
||||||
color: UiColors.white.withValues(alpha: 0.6),
|
color: UiColors.white.withValues(alpha: 0.6),
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ library;
|
|||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
|
||||||
import 'src/data/repositories_impl/hub_repository_impl.dart';
|
import 'src/data/repositories_impl/hub_repository_impl.dart';
|
||||||
import 'src/domain/repositories/hub_repository_interface.dart';
|
import 'src/domain/repositories/hub_repository_interface.dart';
|
||||||
import 'src/domain/usecases/assign_nfc_tag_usecase.dart';
|
import 'src/domain/usecases/assign_nfc_tag_usecase.dart';
|
||||||
@@ -23,12 +22,7 @@ class ClientHubsModule extends Module {
|
|||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
// Repositories
|
// Repositories
|
||||||
i.addLazySingleton<HubRepositoryInterface>(
|
i.addLazySingleton<HubRepositoryInterface>(HubRepositoryImpl.new);
|
||||||
() => HubRepositoryImpl(
|
|
||||||
firebaseAuth: firebase.FirebaseAuth.instance,
|
|
||||||
dataConnect: ExampleConnector.instance,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// UseCases
|
// UseCases
|
||||||
i.addLazySingleton(GetHubsUseCase.new);
|
i.addLazySingleton(GetHubsUseCase.new);
|
||||||
@@ -37,14 +31,7 @@ class ClientHubsModule extends Module {
|
|||||||
i.addLazySingleton(AssignNfcTagUseCase.new);
|
i.addLazySingleton(AssignNfcTagUseCase.new);
|
||||||
|
|
||||||
// BLoCs
|
// BLoCs
|
||||||
i.add<ClientHubsBloc>(
|
i.add<ClientHubsBloc>(ClientHubsBloc.new);
|
||||||
() => ClientHubsBloc(
|
|
||||||
getHubsUseCase: i.get<GetHubsUseCase>(),
|
|
||||||
createHubUseCase: i.get<CreateHubUseCase>(),
|
|
||||||
deleteHubUseCase: i.get<DeleteHubUseCase>(),
|
|
||||||
assignNfcTagUseCase: i.get<AssignNfcTagUseCase>(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -9,30 +9,26 @@ import 'package:krow_domain/krow_domain.dart' as domain;
|
|||||||
import 'package:krow_domain/krow_domain.dart'
|
import 'package:krow_domain/krow_domain.dart'
|
||||||
show
|
show
|
||||||
HubHasOrdersException,
|
HubHasOrdersException,
|
||||||
HubCreationFailedException,
|
|
||||||
BusinessNotFoundException,
|
BusinessNotFoundException,
|
||||||
NotAuthenticatedException;
|
NotAuthenticatedException;
|
||||||
|
|
||||||
import '../../domain/repositories/hub_repository_interface.dart';
|
import '../../domain/repositories/hub_repository_interface.dart';
|
||||||
|
|
||||||
/// Implementation of [HubRepositoryInterface] backed by Data Connect.
|
/// Implementation of [HubRepositoryInterface] backed by Data Connect.
|
||||||
class HubRepositoryImpl
|
class HubRepositoryImpl implements HubRepositoryInterface {
|
||||||
with dc.DataErrorHandler
|
HubRepositoryImpl({required dc.DataConnectService service})
|
||||||
implements HubRepositoryInterface {
|
: _service = service;
|
||||||
HubRepositoryImpl({
|
|
||||||
required firebase.FirebaseAuth firebaseAuth,
|
|
||||||
required dc.ExampleConnector dataConnect,
|
|
||||||
}) : _firebaseAuth = firebaseAuth,
|
|
||||||
_dataConnect = dataConnect;
|
|
||||||
|
|
||||||
final firebase.FirebaseAuth _firebaseAuth;
|
final dc.DataConnectService _service;
|
||||||
final dc.ExampleConnector _dataConnect;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<domain.Hub>> getHubs() async {
|
Future<List<domain.Hub>> getHubs() async {
|
||||||
final dc.GetBusinessesByUserIdBusinesses business = await _getBusinessForCurrentUser();
|
return _service.run(() async {
|
||||||
final String teamId = await _getOrCreateTeamId(business);
|
final dc.GetBusinessesByUserIdBusinesses business =
|
||||||
return _fetchHubsForTeam(teamId: teamId, businessId: business.id);
|
await _getBusinessForCurrentUser();
|
||||||
|
final String teamId = await _getOrCreateTeamId(business);
|
||||||
|
return _fetchHubsForTeam(teamId: teamId, businessId: business.id);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -48,101 +44,98 @@ class HubRepositoryImpl
|
|||||||
String? country,
|
String? country,
|
||||||
String? zipCode,
|
String? zipCode,
|
||||||
}) async {
|
}) async {
|
||||||
final dc.GetBusinessesByUserIdBusinesses business = await _getBusinessForCurrentUser();
|
return _service.run(() async {
|
||||||
final String teamId = await _getOrCreateTeamId(business);
|
final dc.GetBusinessesByUserIdBusinesses business =
|
||||||
final _PlaceAddress? placeAddress =
|
await _getBusinessForCurrentUser();
|
||||||
placeId == null || placeId.isEmpty ? null : await _fetchPlaceAddress(placeId);
|
final String teamId = await _getOrCreateTeamId(business);
|
||||||
final String? cityValue = city ?? placeAddress?.city ?? business.city;
|
final _PlaceAddress? placeAddress = placeId == null || placeId.isEmpty
|
||||||
final String? stateValue = state ?? placeAddress?.state;
|
? null
|
||||||
final String? streetValue = street ?? placeAddress?.street;
|
: await _fetchPlaceAddress(placeId);
|
||||||
final String? countryValue = country ?? placeAddress?.country;
|
final String? cityValue = city ?? placeAddress?.city ?? business.city;
|
||||||
final String? zipCodeValue = zipCode ?? placeAddress?.zipCode;
|
final String? stateValue = state ?? placeAddress?.state;
|
||||||
|
final String? streetValue = street ?? placeAddress?.street;
|
||||||
|
final String? countryValue = country ?? placeAddress?.country;
|
||||||
|
final String? zipCodeValue = zipCode ?? placeAddress?.zipCode;
|
||||||
|
|
||||||
final OperationResult<dc.CreateTeamHubData, dc.CreateTeamHubVariables>
|
final OperationResult<dc.CreateTeamHubData, dc.CreateTeamHubVariables>
|
||||||
result = await executeProtected(() => _dataConnect
|
result = await _service.connector
|
||||||
.createTeamHub(
|
.createTeamHub(teamId: teamId, hubName: name, address: address)
|
||||||
teamId: teamId,
|
.placeId(placeId)
|
||||||
hubName: name,
|
.latitude(latitude)
|
||||||
address: address,
|
.longitude(longitude)
|
||||||
)
|
.city(cityValue?.isNotEmpty == true ? cityValue : '')
|
||||||
.placeId(placeId)
|
.state(stateValue)
|
||||||
.latitude(latitude)
|
.street(streetValue)
|
||||||
.longitude(longitude)
|
.country(countryValue)
|
||||||
.city(cityValue?.isNotEmpty == true ? cityValue : '')
|
.zipCode(zipCodeValue)
|
||||||
.state(stateValue)
|
.execute();
|
||||||
.street(streetValue)
|
final String createdId = result.data.teamHub_insert.id;
|
||||||
.country(countryValue)
|
|
||||||
.zipCode(zipCodeValue)
|
|
||||||
.execute());
|
|
||||||
final String createdId = result.data.teamHub_insert.id;
|
|
||||||
|
|
||||||
final List<domain.Hub> hubs = await _fetchHubsForTeam(
|
final List<domain.Hub> hubs = await _fetchHubsForTeam(
|
||||||
teamId: teamId,
|
teamId: teamId,
|
||||||
businessId: business.id,
|
businessId: business.id,
|
||||||
);
|
);
|
||||||
domain.Hub? createdHub;
|
domain.Hub? createdHub;
|
||||||
for (final domain.Hub hub in hubs) {
|
for (final domain.Hub hub in hubs) {
|
||||||
if (hub.id == createdId) {
|
if (hub.id == createdId) {
|
||||||
createdHub = hub;
|
createdHub = hub;
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
return createdHub ??
|
||||||
return createdHub ??
|
domain.Hub(
|
||||||
domain.Hub(
|
id: createdId,
|
||||||
id: createdId,
|
businessId: business.id,
|
||||||
businessId: business.id,
|
name: name,
|
||||||
name: name,
|
address: address,
|
||||||
address: address,
|
nfcTagId: null,
|
||||||
nfcTagId: null,
|
status: domain.HubStatus.active,
|
||||||
status: domain.HubStatus.active,
|
);
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> deleteHub(String id) async {
|
Future<void> deleteHub(String id) async {
|
||||||
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
|
return _service.run(() async {
|
||||||
if (businessId == null || businessId.isEmpty) {
|
final String businessId = await _service.getBusinessId();
|
||||||
await _firebaseAuth.signOut();
|
|
||||||
throw const BusinessNotFoundException(
|
|
||||||
technicalMessage: 'Business ID missing from session',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final QueryResult<dc.ListOrdersByBusinessAndTeamHubData,
|
final QueryResult<
|
||||||
dc.ListOrdersByBusinessAndTeamHubVariables> result =
|
dc.ListOrdersByBusinessAndTeamHubData,
|
||||||
await executeProtected(() => _dataConnect
|
dc.ListOrdersByBusinessAndTeamHubVariables
|
||||||
.listOrdersByBusinessAndTeamHub(
|
>
|
||||||
businessId: businessId,
|
result = await _service.connector
|
||||||
teamHubId: id,
|
.listOrdersByBusinessAndTeamHub(businessId: businessId, teamHubId: id)
|
||||||
)
|
.execute();
|
||||||
.execute());
|
|
||||||
|
|
||||||
if (result.data.orders.isNotEmpty) {
|
if (result.data.orders.isNotEmpty) {
|
||||||
throw HubHasOrdersException(
|
throw HubHasOrdersException(
|
||||||
technicalMessage: 'Hub $id has ${result.data.orders.length} orders',
|
technicalMessage: 'Hub $id has ${result.data.orders.length} orders',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await executeProtected(() => _dataConnect.deleteTeamHub(id: id).execute());
|
await _service.connector.deleteTeamHub(id: id).execute();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> assignNfcTag({
|
Future<void> assignNfcTag({required String hubId, required String nfcTagId}) {
|
||||||
required String hubId,
|
throw UnimplementedError(
|
||||||
required String nfcTagId,
|
'NFC tag assignment is not supported for team hubs.',
|
||||||
}) {
|
);
|
||||||
throw UnimplementedError('NFC tag assignment is not supported for team hubs.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<dc.GetBusinessesByUserIdBusinesses> _getBusinessForCurrentUser() async {
|
Future<dc.GetBusinessesByUserIdBusinesses>
|
||||||
|
_getBusinessForCurrentUser() async {
|
||||||
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
|
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
|
||||||
final dc.ClientBusinessSession? cachedBusiness = session?.business;
|
final dc.ClientBusinessSession? cachedBusiness = session?.business;
|
||||||
if (cachedBusiness != null) {
|
if (cachedBusiness != null) {
|
||||||
return dc.GetBusinessesByUserIdBusinesses(
|
return dc.GetBusinessesByUserIdBusinesses(
|
||||||
id: cachedBusiness.id,
|
id: cachedBusiness.id,
|
||||||
businessName: cachedBusiness.businessName,
|
businessName: cachedBusiness.businessName,
|
||||||
userId: _firebaseAuth.currentUser?.uid ?? '',
|
userId: _service.auth.currentUser?.uid ?? '',
|
||||||
rateGroup: const dc.Known<dc.BusinessRateGroup>(dc.BusinessRateGroup.STANDARD),
|
rateGroup: const dc.Known<dc.BusinessRateGroup>(
|
||||||
|
dc.BusinessRateGroup.STANDARD,
|
||||||
|
),
|
||||||
status: const dc.Known<dc.BusinessStatus>(dc.BusinessStatus.ACTIVE),
|
status: const dc.Known<dc.BusinessStatus>(dc.BusinessStatus.ACTIVE),
|
||||||
contactName: cachedBusiness.contactName,
|
contactName: cachedBusiness.contactName,
|
||||||
companyLogoUrl: cachedBusiness.companyLogoUrl,
|
companyLogoUrl: cachedBusiness.companyLogoUrl,
|
||||||
@@ -159,31 +152,32 @@ class HubRepositoryImpl
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final firebase.User? user = _firebaseAuth.currentUser;
|
final firebase.User? user = _service.auth.currentUser;
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
throw const NotAuthenticatedException(
|
throw const NotAuthenticatedException(
|
||||||
technicalMessage: 'No Firebase user in currentUser',
|
technicalMessage: 'No Firebase user in currentUser',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final QueryResult<dc.GetBusinessesByUserIdData,
|
final QueryResult<
|
||||||
dc.GetBusinessesByUserIdVariables> result =
|
dc.GetBusinessesByUserIdData,
|
||||||
await executeProtected(() => _dataConnect.getBusinessesByUserId(
|
dc.GetBusinessesByUserIdVariables
|
||||||
userId: user.uid,
|
>
|
||||||
).execute());
|
result = await _service.connector
|
||||||
|
.getBusinessesByUserId(userId: user.uid)
|
||||||
|
.execute();
|
||||||
if (result.data.businesses.isEmpty) {
|
if (result.data.businesses.isEmpty) {
|
||||||
await _firebaseAuth.signOut();
|
await _service.auth.signOut();
|
||||||
throw BusinessNotFoundException(
|
throw BusinessNotFoundException(
|
||||||
technicalMessage: 'No business found for user ${user.uid}',
|
technicalMessage: 'No business found for user ${user.uid}',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final dc.GetBusinessesByUserIdBusinesses business = result.data.businesses.first;
|
final dc.GetBusinessesByUserIdBusinesses business =
|
||||||
|
result.data.businesses.first;
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
dc.ClientSessionStore.instance.setSession(
|
dc.ClientSessionStore.instance.setSession(
|
||||||
dc.ClientSession(
|
dc.ClientSession(
|
||||||
user: session.user,
|
|
||||||
userPhotoUrl: session.userPhotoUrl,
|
|
||||||
business: dc.ClientBusinessSession(
|
business: dc.ClientBusinessSession(
|
||||||
id: business.id,
|
id: business.id,
|
||||||
businessName: business.businessName,
|
businessName: business.businessName,
|
||||||
@@ -203,26 +197,26 @@ class HubRepositoryImpl
|
|||||||
dc.GetBusinessesByUserIdBusinesses business,
|
dc.GetBusinessesByUserIdBusinesses business,
|
||||||
) async {
|
) async {
|
||||||
final QueryResult<dc.GetTeamsByOwnerIdData, dc.GetTeamsByOwnerIdVariables>
|
final QueryResult<dc.GetTeamsByOwnerIdData, dc.GetTeamsByOwnerIdVariables>
|
||||||
teamsResult = await executeProtected(() => _dataConnect.getTeamsByOwnerId(
|
teamsResult = await _service.connector
|
||||||
ownerId: business.id,
|
.getTeamsByOwnerId(ownerId: business.id)
|
||||||
).execute());
|
.execute();
|
||||||
if (teamsResult.data.teams.isNotEmpty) {
|
if (teamsResult.data.teams.isNotEmpty) {
|
||||||
return teamsResult.data.teams.first.id;
|
return teamsResult.data.teams.first.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
final dc.CreateTeamVariablesBuilder createTeamBuilder = _dataConnect.createTeam(
|
final dc.CreateTeamVariablesBuilder createTeamBuilder = _service.connector
|
||||||
teamName: '${business.businessName} Team',
|
.createTeam(
|
||||||
ownerId: business.id,
|
teamName: '${business.businessName} Team',
|
||||||
ownerName: business.contactName ?? '',
|
ownerId: business.id,
|
||||||
ownerRole: 'OWNER',
|
ownerName: business.contactName ?? '',
|
||||||
);
|
ownerRole: 'OWNER',
|
||||||
|
);
|
||||||
if (business.email != null) {
|
if (business.email != null) {
|
||||||
createTeamBuilder.email(business.email);
|
createTeamBuilder.email(business.email);
|
||||||
}
|
}
|
||||||
|
|
||||||
final OperationResult<dc.CreateTeamData, dc.CreateTeamVariables>
|
final OperationResult<dc.CreateTeamData, dc.CreateTeamVariables>
|
||||||
createTeamResult =
|
createTeamResult = await createTeamBuilder.execute();
|
||||||
await executeProtected(() => createTeamBuilder.execute());
|
|
||||||
final String teamId = createTeamResult.data.team_insert.id;
|
final String teamId = createTeamResult.data.team_insert.id;
|
||||||
|
|
||||||
return teamId;
|
return teamId;
|
||||||
@@ -232,11 +226,13 @@ class HubRepositoryImpl
|
|||||||
required String teamId,
|
required String teamId,
|
||||||
required String businessId,
|
required String businessId,
|
||||||
}) async {
|
}) async {
|
||||||
final QueryResult<dc.GetTeamHubsByTeamIdData,
|
final QueryResult<
|
||||||
dc.GetTeamHubsByTeamIdVariables> hubsResult =
|
dc.GetTeamHubsByTeamIdData,
|
||||||
await executeProtected(() => _dataConnect.getTeamHubsByTeamId(
|
dc.GetTeamHubsByTeamIdVariables
|
||||||
teamId: teamId,
|
>
|
||||||
).execute());
|
hubsResult = await _service.connector
|
||||||
|
.getTeamHubsByTeamId(teamId: teamId)
|
||||||
|
.execute();
|
||||||
|
|
||||||
return hubsResult.data.teamHubs
|
return hubsResult.data.teamHubs
|
||||||
.map(
|
.map(
|
||||||
@@ -246,10 +242,9 @@ class HubRepositoryImpl
|
|||||||
name: hub.hubName,
|
name: hub.hubName,
|
||||||
address: hub.address,
|
address: hub.address,
|
||||||
nfcTagId: null,
|
nfcTagId: null,
|
||||||
status:
|
status: hub.isActive
|
||||||
hub.isActive
|
? domain.HubStatus.active
|
||||||
? domain.HubStatus.active
|
: domain.HubStatus.inactive,
|
||||||
: domain.HubStatus.inactive,
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.toList();
|
.toList();
|
||||||
@@ -262,7 +257,7 @@ class HubRepositoryImpl
|
|||||||
<String, String>{
|
<String, String>{
|
||||||
'place_id': placeId,
|
'place_id': placeId,
|
||||||
'fields': 'address_component',
|
'fields': 'address_component',
|
||||||
'key': AppConfig.googlePlacesApiKey,
|
'key': AppConfig.googleMapsApiKey,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
@@ -294,7 +289,8 @@ class HubRepositoryImpl
|
|||||||
|
|
||||||
for (final dynamic entry in components) {
|
for (final dynamic entry in components) {
|
||||||
final Map<String, dynamic> component = entry as Map<String, dynamic>;
|
final Map<String, dynamic> component = entry as Map<String, dynamic>;
|
||||||
final List<dynamic> types = component['types'] as List<dynamic>? ?? <dynamic>[];
|
final List<dynamic> types =
|
||||||
|
component['types'] as List<dynamic>? ?? <dynamic>[];
|
||||||
final String? longName = component['long_name'] as String?;
|
final String? longName = component['long_name'] as String?;
|
||||||
final String? shortName = component['short_name'] as String?;
|
final String? shortName = component['short_name'] as String?;
|
||||||
|
|
||||||
|
|||||||
@@ -227,23 +227,23 @@ class ClientHubsPage extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _confirmDeleteHub(BuildContext context, Hub hub) async {
|
Future<void> _confirmDeleteHub(BuildContext context, Hub hub) async {
|
||||||
final String hubName = hub.name.isEmpty ? 'this hub' : hub.name;
|
final String hubName = hub.name.isEmpty ? t.client_hubs.title : hub.name;
|
||||||
return showDialog<void>(
|
return showDialog<void>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
builder: (BuildContext dialogContext) {
|
builder: (BuildContext dialogContext) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text('Confirm Hub Deletion'),
|
title: Text(t.client_hubs.delete_dialog.title),
|
||||||
content: Column(
|
content: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text('Are you sure you want to delete "$hubName"?'),
|
Text(t.client_hubs.delete_dialog.message(hubName: hubName)),
|
||||||
const SizedBox(height: UiConstants.space2),
|
const SizedBox(height: UiConstants.space2),
|
||||||
const Text('This action cannot be undone.'),
|
Text(t.client_hubs.delete_dialog.undo_warning),
|
||||||
const SizedBox(height: UiConstants.space2),
|
const SizedBox(height: UiConstants.space2),
|
||||||
Text(
|
Text(
|
||||||
'Note that if there are any shifts/orders assigned to this hub we shouldn\'t be able to delete the hub.',
|
t.client_hubs.delete_dialog.dependency_warning,
|
||||||
style: UiTypography.footnote1r.copyWith(
|
style: UiTypography.footnote1r.copyWith(
|
||||||
color: UiColors.textSecondary,
|
color: UiColors.textSecondary,
|
||||||
),
|
),
|
||||||
@@ -253,7 +253,7 @@ class ClientHubsPage extends StatelessWidget {
|
|||||||
actions: <Widget>[
|
actions: <Widget>[
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Modular.to.pop(),
|
onPressed: () => Modular.to.pop(),
|
||||||
child: const Text('Cancel'),
|
child: Text(t.client_hubs.delete_dialog.cancel),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
@@ -265,7 +265,7 @@ class ClientHubsPage extends StatelessWidget {
|
|||||||
style: TextButton.styleFrom(
|
style: TextButton.styleFrom(
|
||||||
foregroundColor: UiColors.destructive,
|
foregroundColor: UiColors.destructive,
|
||||||
),
|
),
|
||||||
child: const Text('Delete'),
|
child: Text(t.client_hubs.delete_dialog.delete),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class HubAddressAutocomplete extends StatelessWidget {
|
|||||||
return GooglePlaceAutoCompleteTextField(
|
return GooglePlaceAutoCompleteTextField(
|
||||||
textEditingController: controller,
|
textEditingController: controller,
|
||||||
focusNode: focusNode,
|
focusNode: focusNode,
|
||||||
googleAPIKey: AppConfig.googlePlacesApiKey,
|
googleAPIKey: AppConfig.googleMapsApiKey,
|
||||||
debounceTime: 500,
|
debounceTime: 500,
|
||||||
countries: HubsConstants.supportedCountries,
|
countries: HubsConstants.supportedCountries,
|
||||||
isLatLngRequired: true,
|
isLatLngRequired: true,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import 'package:firebase_auth/firebase_auth.dart';
|
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
import 'src/data/repositories_impl/settings_repository_impl.dart';
|
import 'src/data/repositories_impl/settings_repository_impl.dart';
|
||||||
import 'src/domain/repositories/settings_repository_interface.dart';
|
import 'src/domain/repositories/settings_repository_interface.dart';
|
||||||
import 'src/domain/usecases/sign_out_usecase.dart';
|
import 'src/domain/usecases/sign_out_usecase.dart';
|
||||||
@@ -9,20 +9,19 @@ import 'src/presentation/pages/client_settings_page.dart';
|
|||||||
|
|
||||||
/// A [Module] for the client settings feature.
|
/// A [Module] for the client settings feature.
|
||||||
class ClientSettingsModule extends Module {
|
class ClientSettingsModule extends Module {
|
||||||
|
@override
|
||||||
|
List<Module> get imports => <Module>[DataConnectModule()];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
// Repositories
|
// Repositories
|
||||||
i.addLazySingleton<SettingsRepositoryInterface>(
|
i.addLazySingleton<SettingsRepositoryInterface>(SettingsRepositoryImpl.new);
|
||||||
() => SettingsRepositoryImpl(firebaseAuth: FirebaseAuth.instance),
|
|
||||||
);
|
|
||||||
|
|
||||||
// UseCases
|
// UseCases
|
||||||
i.addLazySingleton(SignOutUseCase.new);
|
i.addLazySingleton(SignOutUseCase.new);
|
||||||
|
|
||||||
// BLoCs
|
// BLoCs
|
||||||
i.add<ClientSettingsBloc>(
|
i.add<ClientSettingsBloc>(ClientSettingsBloc.new);
|
||||||
() => ClientSettingsBloc(signOutUseCase: i.get<SignOutUseCase>()),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
import 'package:firebase_auth/firebase_auth.dart';
|
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||||
|
|
||||||
import '../../domain/repositories/settings_repository_interface.dart';
|
import '../../domain/repositories/settings_repository_interface.dart';
|
||||||
|
|
||||||
/// Implementation of [SettingsRepositoryInterface].
|
/// Implementation of [SettingsRepositoryInterface].
|
||||||
///
|
///
|
||||||
/// This implementation delegates authentication operations to [FirebaseAuth].
|
/// This implementation delegates authentication operations to [DataConnectService].
|
||||||
class SettingsRepositoryImpl implements SettingsRepositoryInterface {
|
class SettingsRepositoryImpl implements SettingsRepositoryInterface {
|
||||||
/// Creates a [SettingsRepositoryImpl] with the required [_firebaseAuth].
|
/// Creates a [SettingsRepositoryImpl] with the required [_service].
|
||||||
const SettingsRepositoryImpl({required this.firebaseAuth});
|
const SettingsRepositoryImpl({required dc.DataConnectService service}) : _service = service;
|
||||||
|
|
||||||
/// The Firebase Auth instance.
|
/// The Data Connect service.
|
||||||
final FirebaseAuth firebaseAuth;
|
final dc.DataConnectService _service;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> signOut() async {
|
Future<void> signOut() async {
|
||||||
try {
|
return _service.run(() async {
|
||||||
await firebaseAuth.signOut();
|
await _service.auth.signOut();
|
||||||
} catch (e) {
|
});
|
||||||
throw Exception('Error signing out: ${e.toString()}');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class ClientSettingsPage extends StatelessWidget {
|
|||||||
message: 'Signed out successfully',
|
message: 'Signed out successfully',
|
||||||
type: UiSnackbarType.success,
|
type: UiSnackbarType.success,
|
||||||
);
|
);
|
||||||
Modular.to.toClientRoot();
|
Modular.to.toClientGetStartedPage();
|
||||||
}
|
}
|
||||||
if (state is ClientSettingsError) {
|
if (state is ClientSettingsError) {
|
||||||
UiSnackbar.show(
|
UiSnackbar.show(
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ class SettingsProfileHeader extends StatelessWidget {
|
|||||||
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
|
final dc.ClientSession? session = dc.ClientSessionStore.instance.session;
|
||||||
final String businessName =
|
final String businessName =
|
||||||
session?.business?.businessName ?? 'Your Company';
|
session?.business?.businessName ?? 'Your Company';
|
||||||
final String email = session?.user.email ?? 'client@example.com';
|
final String email = session?.business?.email ?? 'client@example.com';
|
||||||
final String? photoUrl = session?.userPhotoUrl;
|
final String? photoUrl = session?.business?.companyLogoUrl;
|
||||||
final String avatarLetter = businessName.trim().isNotEmpty
|
final String avatarLetter = businessName.trim().isNotEmpty
|
||||||
? businessName.trim()[0].toUpperCase()
|
? businessName.trim()[0].toUpperCase()
|
||||||
: 'C';
|
: 'C';
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
|
||||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
|
||||||
@@ -6,151 +5,132 @@ import 'package:krow_domain/krow_domain.dart' as domain;
|
|||||||
import '../../domain/repositories/i_view_orders_repository.dart';
|
import '../../domain/repositories/i_view_orders_repository.dart';
|
||||||
|
|
||||||
/// Implementation of [IViewOrdersRepository] using Data Connect.
|
/// Implementation of [IViewOrdersRepository] using Data Connect.
|
||||||
class ViewOrdersRepositoryImpl
|
class ViewOrdersRepositoryImpl implements IViewOrdersRepository {
|
||||||
with dc.DataErrorHandler
|
final dc.DataConnectService _service;
|
||||||
implements IViewOrdersRepository {
|
|
||||||
final firebase.FirebaseAuth _firebaseAuth;
|
|
||||||
final dc.ExampleConnector _dataConnect;
|
|
||||||
|
|
||||||
ViewOrdersRepositoryImpl({
|
ViewOrdersRepositoryImpl({
|
||||||
required firebase.FirebaseAuth firebaseAuth,
|
required dc.DataConnectService service,
|
||||||
required dc.ExampleConnector dataConnect,
|
}) : _service = service;
|
||||||
}) : _firebaseAuth = firebaseAuth,
|
|
||||||
_dataConnect = dataConnect;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<domain.OrderItem>> getOrdersForRange({
|
Future<List<domain.OrderItem>> getOrdersForRange({
|
||||||
required DateTime start,
|
required DateTime start,
|
||||||
required DateTime end,
|
required DateTime end,
|
||||||
}) async {
|
}) async {
|
||||||
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
|
return _service.run(() async {
|
||||||
if (businessId == null || businessId.isEmpty) {
|
final String businessId = await _service.getBusinessId();
|
||||||
await _firebaseAuth.signOut();
|
|
||||||
throw Exception('Business is missing. Please sign in again.');
|
|
||||||
}
|
|
||||||
|
|
||||||
final fdc.Timestamp startTimestamp = _toTimestamp(_startOfDay(start));
|
|
||||||
final fdc.Timestamp endTimestamp = _toTimestamp(_endOfDay(end));
|
|
||||||
final fdc.QueryResult<dc.ListShiftRolesByBusinessAndDateRangeData,
|
|
||||||
dc.ListShiftRolesByBusinessAndDateRangeVariables> result =
|
|
||||||
await executeProtected(() => _dataConnect
|
|
||||||
.listShiftRolesByBusinessAndDateRange(
|
|
||||||
businessId: businessId,
|
|
||||||
start: startTimestamp,
|
|
||||||
end: endTimestamp,
|
|
||||||
)
|
|
||||||
.execute());
|
|
||||||
print(
|
|
||||||
'ViewOrders range start=${start.toIso8601String()} end=${end.toIso8601String()} shiftRoles=${result.data.shiftRoles.length}',
|
|
||||||
);
|
|
||||||
|
|
||||||
final String businessName =
|
|
||||||
dc.ClientSessionStore.instance.session?.business?.businessName ??
|
|
||||||
'Your Company';
|
|
||||||
|
|
||||||
return result.data.shiftRoles.map((dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole) {
|
|
||||||
final DateTime? shiftDate = shiftRole.shift.date?.toDateTime().toLocal();
|
|
||||||
final String dateStr = shiftDate == null
|
|
||||||
? ''
|
|
||||||
: DateFormat('yyyy-MM-dd').format(shiftDate);
|
|
||||||
final String startTime = _formatTime(shiftRole.startTime);
|
|
||||||
final String endTime = _formatTime(shiftRole.endTime);
|
|
||||||
final int filled = shiftRole.assigned ?? 0;
|
|
||||||
final int workersNeeded = shiftRole.count;
|
|
||||||
final double hours = shiftRole.hours ?? 0;
|
|
||||||
final double totalValue = shiftRole.totalValue ?? 0;
|
|
||||||
final double hourlyRate = _hourlyRate(shiftRole.totalValue, shiftRole.hours);
|
|
||||||
// final String status = filled >= workersNeeded ? 'filled' : 'open';
|
|
||||||
final String status = shiftRole.shift.status?.stringValue ?? 'OPEN';
|
|
||||||
|
|
||||||
|
final fdc.Timestamp startTimestamp = _service.toTimestamp(_startOfDay(start));
|
||||||
|
final fdc.Timestamp endTimestamp = _service.toTimestamp(_endOfDay(end));
|
||||||
|
final fdc.QueryResult<dc.ListShiftRolesByBusinessAndDateRangeData,
|
||||||
|
dc.ListShiftRolesByBusinessAndDateRangeVariables> result =
|
||||||
|
await _service.connector
|
||||||
|
.listShiftRolesByBusinessAndDateRange(
|
||||||
|
businessId: businessId,
|
||||||
|
start: startTimestamp,
|
||||||
|
end: endTimestamp,
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
print(
|
print(
|
||||||
'ViewOrders item: date=$dateStr status=$status shiftId=${shiftRole.shiftId} '
|
'ViewOrders range start=${start.toIso8601String()} end=${end.toIso8601String()} shiftRoles=${result.data.shiftRoles.length}',
|
||||||
'roleId=${shiftRole.roleId} start=${shiftRole.startTime?.toJson()} '
|
|
||||||
'end=${shiftRole.endTime?.toJson()} hours=$hours totalValue=$totalValue',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
final String eventName =
|
final String businessName =
|
||||||
shiftRole.shift.order.eventName ?? shiftRole.shift.title;
|
dc.ClientSessionStore.instance.session?.business?.businessName ?? 'Your Company';
|
||||||
|
|
||||||
return domain.OrderItem(
|
return result.data.shiftRoles.map((dc.ListShiftRolesByBusinessAndDateRangeShiftRoles shiftRole) {
|
||||||
id: _shiftRoleKey(shiftRole.shiftId, shiftRole.roleId),
|
final DateTime? shiftDate = shiftRole.shift.date?.toDateTime().toLocal();
|
||||||
orderId: shiftRole.shift.order.id,
|
final String dateStr = shiftDate == null ? '' : DateFormat('yyyy-MM-dd').format(shiftDate);
|
||||||
title: '${shiftRole.role.name} - $eventName',
|
final String startTime = _formatTime(shiftRole.startTime);
|
||||||
clientName: businessName,
|
final String endTime = _formatTime(shiftRole.endTime);
|
||||||
status: status,
|
final int filled = shiftRole.assigned ?? 0;
|
||||||
date: dateStr,
|
final int workersNeeded = shiftRole.count;
|
||||||
startTime: startTime,
|
final double hours = shiftRole.hours ?? 0;
|
||||||
endTime: endTime,
|
final double totalValue = shiftRole.totalValue ?? 0;
|
||||||
location: shiftRole.shift.location ?? '',
|
final double hourlyRate = _hourlyRate(shiftRole.totalValue, shiftRole.hours);
|
||||||
locationAddress: shiftRole.shift.locationAddress ?? '',
|
// final String status = filled >= workersNeeded ? 'filled' : 'open';
|
||||||
filled: filled,
|
final String status = shiftRole.shift.status?.stringValue ?? 'OPEN';
|
||||||
workersNeeded: workersNeeded,
|
|
||||||
hourlyRate: hourlyRate,
|
print(
|
||||||
hours: hours,
|
'ViewOrders item: date=$dateStr status=$status shiftId=${shiftRole.shiftId} '
|
||||||
totalValue: totalValue,
|
'roleId=${shiftRole.roleId} start=${shiftRole.startTime?.toJson()} '
|
||||||
confirmedApps: const <Map<String, dynamic>>[],
|
'end=${shiftRole.endTime?.toJson()} hours=$hours totalValue=$totalValue',
|
||||||
);
|
);
|
||||||
}).toList();
|
|
||||||
|
final String eventName = shiftRole.shift.order.eventName ?? shiftRole.shift.title;
|
||||||
|
|
||||||
|
return domain.OrderItem(
|
||||||
|
id: _shiftRoleKey(shiftRole.shiftId, shiftRole.roleId),
|
||||||
|
orderId: shiftRole.shift.order.id,
|
||||||
|
title: '${shiftRole.role.name} - $eventName',
|
||||||
|
clientName: businessName,
|
||||||
|
status: status,
|
||||||
|
date: dateStr,
|
||||||
|
startTime: startTime,
|
||||||
|
endTime: endTime,
|
||||||
|
location: shiftRole.shift.location ?? '',
|
||||||
|
locationAddress: shiftRole.shift.locationAddress ?? '',
|
||||||
|
filled: filled,
|
||||||
|
workersNeeded: workersNeeded,
|
||||||
|
hourlyRate: hourlyRate,
|
||||||
|
hours: hours,
|
||||||
|
totalValue: totalValue,
|
||||||
|
confirmedApps: const <Map<String, dynamic>>[],
|
||||||
|
);
|
||||||
|
}).toList();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<Map<String, List<Map<String, dynamic>>>> getAcceptedApplicationsForDay(
|
Future<Map<String, List<Map<String, dynamic>>>> getAcceptedApplicationsForDay(
|
||||||
DateTime day,
|
DateTime day,
|
||||||
) async {
|
) async {
|
||||||
final String? businessId = dc.ClientSessionStore.instance.session?.business?.id;
|
return _service.run(() async {
|
||||||
if (businessId == null || businessId.isEmpty) {
|
final String businessId = await _service.getBusinessId();
|
||||||
await _firebaseAuth.signOut();
|
|
||||||
throw Exception('Business is missing. Please sign in again.');
|
|
||||||
}
|
|
||||||
|
|
||||||
final fdc.Timestamp dayStart = _toTimestamp(_startOfDay(day));
|
final fdc.Timestamp dayStart = _service.toTimestamp(_startOfDay(day));
|
||||||
final fdc.Timestamp dayEnd = _toTimestamp(_endOfDay(day));
|
final fdc.Timestamp dayEnd = _service.toTimestamp(_endOfDay(day));
|
||||||
final fdc.QueryResult<dc.ListAcceptedApplicationsByBusinessForDayData,
|
final fdc.QueryResult<dc.ListAcceptedApplicationsByBusinessForDayData,
|
||||||
dc.ListAcceptedApplicationsByBusinessForDayVariables> result =
|
dc.ListAcceptedApplicationsByBusinessForDayVariables> result =
|
||||||
await executeProtected(() => _dataConnect
|
await _service.connector
|
||||||
.listAcceptedApplicationsByBusinessForDay(
|
.listAcceptedApplicationsByBusinessForDay(
|
||||||
businessId: businessId,
|
businessId: businessId,
|
||||||
dayStart: dayStart,
|
dayStart: dayStart,
|
||||||
dayEnd: dayEnd,
|
dayEnd: dayEnd,
|
||||||
)
|
)
|
||||||
.execute());
|
.execute();
|
||||||
|
|
||||||
print(
|
|
||||||
'ViewOrders day=${day.toIso8601String()} applications=${result.data.applications.length}',
|
|
||||||
);
|
|
||||||
|
|
||||||
final Map<String, List<Map<String, dynamic>>> grouped = <String, List<Map<String, dynamic>>>{};
|
|
||||||
for (final dc.ListAcceptedApplicationsByBusinessForDayApplications application in result.data.applications) {
|
|
||||||
print(
|
print(
|
||||||
'ViewOrders app: shiftId=${application.shiftId} roleId=${application.roleId} '
|
'ViewOrders day=${day.toIso8601String()} applications=${result.data.applications.length}',
|
||||||
'checkIn=${application.checkInTime?.toJson()} checkOut=${application.checkOutTime?.toJson()}',
|
|
||||||
);
|
);
|
||||||
final String key = _shiftRoleKey(application.shiftId, application.roleId);
|
|
||||||
grouped.putIfAbsent(key, () => <Map<String, dynamic>>[]);
|
final Map<String, List<Map<String, dynamic>>> grouped = <String, List<Map<String, dynamic>>>{};
|
||||||
grouped[key]!.add(<String, dynamic>{
|
for (final dc.ListAcceptedApplicationsByBusinessForDayApplications application
|
||||||
'id': application.id,
|
in result.data.applications) {
|
||||||
'worker_id': application.staff.id,
|
print(
|
||||||
'worker_name': application.staff.fullName,
|
'ViewOrders app: shiftId=${application.shiftId} roleId=${application.roleId} '
|
||||||
'status': 'confirmed',
|
'checkIn=${application.checkInTime?.toJson()} checkOut=${application.checkOutTime?.toJson()}',
|
||||||
'photo_url': application.staff.photoUrl,
|
);
|
||||||
'phone': application.staff.phone,
|
final String key = _shiftRoleKey(application.shiftId, application.roleId);
|
||||||
'rating': application.staff.averageRating,
|
grouped.putIfAbsent(key, () => <Map<String, dynamic>>[]);
|
||||||
});
|
grouped[key]!.add(<String, dynamic>{
|
||||||
}
|
'id': application.id,
|
||||||
return grouped;
|
'worker_id': application.staff.id,
|
||||||
|
'worker_name': application.staff.fullName,
|
||||||
|
'status': 'confirmed',
|
||||||
|
'photo_url': application.staff.photoUrl,
|
||||||
|
'phone': application.staff.phone,
|
||||||
|
'rating': application.staff.averageRating,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return grouped;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
String _shiftRoleKey(String shiftId, String roleId) {
|
String _shiftRoleKey(String shiftId, String roleId) {
|
||||||
return '$shiftId:$roleId';
|
return '$shiftId:$roleId';
|
||||||
}
|
}
|
||||||
|
|
||||||
fdc.Timestamp _toTimestamp(DateTime dateTime) {
|
|
||||||
final DateTime utc = dateTime.toUtc();
|
|
||||||
final int seconds = utc.millisecondsSinceEpoch ~/ 1000;
|
|
||||||
final int nanoseconds = (utc.microsecondsSinceEpoch % 1000000) * 1000;
|
|
||||||
return fdc.Timestamp(nanoseconds, seconds);
|
|
||||||
}
|
|
||||||
|
|
||||||
DateTime _startOfDay(DateTime dateTime) {
|
DateTime _startOfDay(DateTime dateTime) {
|
||||||
return DateTime(dateTime.year, dateTime.month, dateTime.day);
|
return DateTime(dateTime.year, dateTime.month, dateTime.day);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,23 +80,6 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Formats the date string for display.
|
|
||||||
String _formatDate({required String dateStr}) {
|
|
||||||
try {
|
|
||||||
final DateTime date = DateTime.parse(dateStr);
|
|
||||||
final DateTime now = DateTime.now();
|
|
||||||
final DateTime today = DateTime(now.year, now.month, now.day);
|
|
||||||
final DateTime tomorrow = today.add(const Duration(days: 1));
|
|
||||||
final DateTime checkDate = DateTime(date.year, date.month, date.day);
|
|
||||||
|
|
||||||
if (checkDate == today) return 'Today';
|
|
||||||
if (checkDate == tomorrow) return 'Tomorrow';
|
|
||||||
return DateFormat('EEE, MMM d').format(date);
|
|
||||||
} catch (_) {
|
|
||||||
return dateStr;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Formats the time string for display.
|
/// Formats the time string for display.
|
||||||
String _formatTime({required String timeStr}) {
|
String _formatTime({required String timeStr}) {
|
||||||
if (timeStr.isEmpty) return '';
|
if (timeStr.isEmpty) return '';
|
||||||
@@ -279,19 +262,19 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
|
|||||||
_buildStatItem(
|
_buildStatItem(
|
||||||
icon: UiIcons.dollar,
|
icon: UiIcons.dollar,
|
||||||
value: '\$${cost.round()}',
|
value: '\$${cost.round()}',
|
||||||
label: 'Total',
|
label: t.client_view_orders.card.total,
|
||||||
),
|
),
|
||||||
_buildStatDivider(),
|
_buildStatDivider(),
|
||||||
_buildStatItem(
|
_buildStatItem(
|
||||||
icon: UiIcons.clock,
|
icon: UiIcons.clock,
|
||||||
value: hours.toStringAsFixed(1),
|
value: hours.toStringAsFixed(1),
|
||||||
label: 'Hrs',
|
label: t.client_view_orders.card.hrs,
|
||||||
),
|
),
|
||||||
_buildStatDivider(),
|
_buildStatDivider(),
|
||||||
_buildStatItem(
|
_buildStatItem(
|
||||||
icon: UiIcons.users,
|
icon: UiIcons.users,
|
||||||
value: '${order.workersNeeded}',
|
value: '${order.workersNeeded}',
|
||||||
label: 'Workers',
|
label: t.client_create_order.one_time.workers_label,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -303,14 +286,14 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
|
|||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildTimeDisplay(
|
child: _buildTimeDisplay(
|
||||||
label: 'Clock In',
|
label: t.client_view_orders.card.clock_in,
|
||||||
time: _formatTime(timeStr: order.startTime),
|
time: _formatTime(timeStr: order.startTime),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: UiConstants.space3),
|
const SizedBox(width: UiConstants.space3),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: _buildTimeDisplay(
|
child: _buildTimeDisplay(
|
||||||
label: 'Clock Out',
|
label: t.client_view_orders.card.clock_out,
|
||||||
time: _formatTime(timeStr: order.endTime),
|
time: _formatTime(timeStr: order.endTime),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -341,8 +324,8 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
|
|||||||
const SizedBox(width: UiConstants.space2),
|
const SizedBox(width: UiConstants.space2),
|
||||||
Text(
|
Text(
|
||||||
coveragePercent == 100
|
coveragePercent == 100
|
||||||
? 'All Workers Confirmed'
|
? t.client_view_orders.card.all_confirmed
|
||||||
: '${order.workersNeeded} Workers Needed',
|
: t.client_view_orders.card.workers_needed(count: order.workersNeeded),
|
||||||
style: UiTypography.body2m.textPrimary,
|
style: UiTypography.body2m.textPrimary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -378,7 +361,7 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
|
|||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 12),
|
padding: const EdgeInsets.only(left: 12),
|
||||||
child: Text(
|
child: Text(
|
||||||
'+${order.confirmedApps.length - 3} more',
|
t.client_view_orders.card.show_more_workers(count: order.confirmedApps.length - 3),
|
||||||
style: UiTypography.footnote2r.textSecondary,
|
style: UiTypography.footnote2r.textSecondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -408,13 +391,13 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
|
|||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(
|
Text(
|
||||||
'CONFIRMED WORKERS',
|
t.client_view_orders.card.confirmed_workers_title,
|
||||||
style: UiTypography.footnote2b.textSecondary,
|
style: UiTypography.footnote2b.textSecondary,
|
||||||
),
|
),
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onTap: () {},
|
onTap: () {},
|
||||||
child: Text(
|
child: Text(
|
||||||
'Message All',
|
t.client_view_orders.card.message_all,
|
||||||
style: UiTypography.footnote2b.copyWith(
|
style: UiTypography.footnote2b.copyWith(
|
||||||
color: UiColors.primary,
|
color: UiColors.primary,
|
||||||
),
|
),
|
||||||
@@ -433,7 +416,7 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
|
|||||||
child: TextButton(
|
child: TextButton(
|
||||||
onPressed: () {},
|
onPressed: () {},
|
||||||
child: Text(
|
child: Text(
|
||||||
'Show ${order.confirmedApps.length - 5} more workers',
|
t.client_view_orders.card.show_more_workers(count: order.confirmedApps.length - 5),
|
||||||
style: UiTypography.body2m.copyWith(
|
style: UiTypography.body2m.copyWith(
|
||||||
color: UiColors.primary,
|
color: UiColors.primary,
|
||||||
),
|
),
|
||||||
@@ -569,7 +552,7 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
|
|||||||
borderRadius: UiConstants.radiusSm,
|
borderRadius: UiConstants.radiusSm,
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'Checked In',
|
t.client_view_orders.card.checked_in,
|
||||||
style: UiTypography.titleUppercase4m.copyWith(
|
style: UiTypography.titleUppercase4m.copyWith(
|
||||||
color: UiColors.textSuccess,
|
color: UiColors.textSuccess,
|
||||||
),
|
),
|
||||||
@@ -615,16 +598,16 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
|
|||||||
context: context,
|
context: context,
|
||||||
builder: (BuildContext context) {
|
builder: (BuildContext context) {
|
||||||
return AlertDialog(
|
return AlertDialog(
|
||||||
title: const Text('Call'),
|
title: Text(t.client_view_orders.card.call_dialog.title),
|
||||||
content: Text('Do you want to call $phone?'),
|
content: Text(t.client_view_orders.card.call_dialog.message(phone: phone)),
|
||||||
actions: <Widget>[
|
actions: <Widget>[
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(false),
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
child: const Text('Cancel'),
|
child: Text(t.common.cancel),
|
||||||
),
|
),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
child: const Text('Call'),
|
child: Text(t.client_view_orders.card.call_dialog.title),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -829,10 +812,7 @@ class _OrderEditSheetState extends State<_OrderEditSheet> {
|
|||||||
final String dateText = orderDate == null
|
final String dateText = orderDate == null
|
||||||
? widget.order.date
|
? widget.order.date
|
||||||
: DateFormat('yyyy-MM-dd').format(orderDate);
|
: DateFormat('yyyy-MM-dd').format(orderDate);
|
||||||
final String location = firstShift.order.teamHub?.hubName ??
|
final String location = firstShift.order.teamHub.hubName;
|
||||||
firstShift.locationAddress ??
|
|
||||||
firstShift.location ??
|
|
||||||
widget.order.locationAddress;
|
|
||||||
|
|
||||||
_dateController.text = dateText;
|
_dateController.text = dateText;
|
||||||
_globalLocationController.text = location;
|
_globalLocationController.text = location;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
|
||||||
|
|
||||||
import 'data/repositories/view_orders_repository_impl.dart';
|
import 'data/repositories/view_orders_repository_impl.dart';
|
||||||
import 'domain/repositories/i_view_orders_repository.dart';
|
import 'domain/repositories/i_view_orders_repository.dart';
|
||||||
@@ -21,24 +20,14 @@ class ViewOrdersModule extends Module {
|
|||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
// Repositories
|
// Repositories
|
||||||
i.add<IViewOrdersRepository>(
|
i.add<IViewOrdersRepository>(ViewOrdersRepositoryImpl.new);
|
||||||
() => ViewOrdersRepositoryImpl(
|
|
||||||
firebaseAuth: firebase.FirebaseAuth.instance,
|
|
||||||
dataConnect: ExampleConnector.instance,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// UseCases
|
// UseCases
|
||||||
i.add(GetOrdersUseCase.new);
|
i.add(GetOrdersUseCase.new);
|
||||||
i.add(GetAcceptedApplicationsForDayUseCase.new);
|
i.add(GetAcceptedApplicationsForDayUseCase.new);
|
||||||
|
|
||||||
// BLoCs
|
// BLoCs
|
||||||
i.add(
|
i.add(ViewOrdersCubit.new);
|
||||||
() => ViewOrdersCubit(
|
|
||||||
getOrdersUseCase: i.get<GetOrdersUseCase>(),
|
|
||||||
getAcceptedAppsUseCase: i.get<GetAcceptedApplicationsForDayUseCase>(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -10,22 +10,15 @@ import '../../domain/ui_entities/auth_mode.dart';
|
|||||||
import '../../domain/repositories/auth_repository_interface.dart';
|
import '../../domain/repositories/auth_repository_interface.dart';
|
||||||
|
|
||||||
/// Implementation of [AuthRepositoryInterface].
|
/// Implementation of [AuthRepositoryInterface].
|
||||||
class AuthRepositoryImpl
|
class AuthRepositoryImpl implements AuthRepositoryInterface {
|
||||||
with DataErrorHandler
|
AuthRepositoryImpl() : _service = DataConnectService.instance;
|
||||||
implements AuthRepositoryInterface {
|
|
||||||
AuthRepositoryImpl({
|
|
||||||
required this.firebaseAuth,
|
|
||||||
required this.dataConnect,
|
|
||||||
});
|
|
||||||
|
|
||||||
final FirebaseAuth firebaseAuth;
|
final DataConnectService _service;
|
||||||
final ExampleConnector dataConnect;
|
|
||||||
Completer<String?>? _pendingVerification;
|
Completer<String?>? _pendingVerification;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Stream<domain.User?> get currentUser => firebaseAuth
|
Stream<domain.User?> get currentUser =>
|
||||||
.authStateChanges()
|
_service.auth.authStateChanges().map((User? firebaseUser) {
|
||||||
.map((User? firebaseUser) {
|
|
||||||
if (firebaseUser == null) {
|
if (firebaseUser == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -44,7 +37,7 @@ class AuthRepositoryImpl
|
|||||||
final Completer<String?> completer = Completer<String?>();
|
final Completer<String?> completer = Completer<String?>();
|
||||||
_pendingVerification = completer;
|
_pendingVerification = completer;
|
||||||
|
|
||||||
await firebaseAuth.verifyPhoneNumber(
|
await _service.auth.verifyPhoneNumber(
|
||||||
phoneNumber: phoneNumber,
|
phoneNumber: phoneNumber,
|
||||||
verificationCompleted: (PhoneAuthCredential credential) {
|
verificationCompleted: (PhoneAuthCredential credential) {
|
||||||
// Skip auto-verification for test numbers to allow manual code entry
|
// Skip auto-verification for test numbers to allow manual code entry
|
||||||
@@ -55,20 +48,24 @@ class AuthRepositoryImpl
|
|||||||
// For real numbers, we can support auto-verification if desired.
|
// For real numbers, we can support auto-verification if desired.
|
||||||
// But since this method returns a verificationId for manual OTP entry,
|
// But since this method returns a verificationId for manual OTP entry,
|
||||||
// we might not handle direct sign-in here unless the architecture changes.
|
// we might not handle direct sign-in here unless the architecture changes.
|
||||||
// Currently, we just ignore it for the completer flow,
|
// Currently, we just ignore it for the completer flow,
|
||||||
// or we could sign in directly if the credential is provided.
|
// or we could sign in directly if the credential is provided.
|
||||||
},
|
},
|
||||||
verificationFailed: (FirebaseAuthException e) {
|
verificationFailed: (FirebaseAuthException e) {
|
||||||
if (!completer.isCompleted) {
|
if (!completer.isCompleted) {
|
||||||
// Map Firebase network errors to NetworkException
|
// Map Firebase network errors to NetworkException
|
||||||
if (e.code == 'network-request-failed' ||
|
if (e.code == 'network-request-failed' ||
|
||||||
e.message?.contains('Unable to resolve host') == true) {
|
e.message?.contains('Unable to resolve host') == true) {
|
||||||
completer.completeError(
|
completer.completeError(
|
||||||
const domain.NetworkException(technicalMessage: 'Auth network failure'),
|
const domain.NetworkException(
|
||||||
|
technicalMessage: 'Auth network failure',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
completer.completeError(
|
completer.completeError(
|
||||||
domain.SignInFailedException(technicalMessage: 'Firebase ${e.code}: ${e.message}'),
|
domain.SignInFailedException(
|
||||||
|
technicalMessage: 'Firebase ${e.code}: ${e.message}',
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -101,7 +98,8 @@ class AuthRepositoryImpl
|
|||||||
@override
|
@override
|
||||||
Future<void> signOut() {
|
Future<void> signOut() {
|
||||||
StaffSessionStore.instance.clear();
|
StaffSessionStore.instance.clear();
|
||||||
return firebaseAuth.signOut();
|
_service.clearCache();
|
||||||
|
return _service.auth.signOut();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Verifies an OTP code and returns the authenticated user.
|
/// Verifies an OTP code and returns the authenticated user.
|
||||||
@@ -115,90 +113,103 @@ class AuthRepositoryImpl
|
|||||||
verificationId: verificationId,
|
verificationId: verificationId,
|
||||||
smsCode: smsCode,
|
smsCode: smsCode,
|
||||||
);
|
);
|
||||||
final UserCredential userCredential = await executeProtected(
|
final UserCredential userCredential = await _service.run(() async {
|
||||||
() async {
|
try {
|
||||||
try {
|
return await _service.auth.signInWithCredential(credential);
|
||||||
return await firebaseAuth.signInWithCredential(credential);
|
} on FirebaseAuthException catch (e) {
|
||||||
} on FirebaseAuthException catch (e) {
|
if (e.code == 'invalid-verification-code') {
|
||||||
if (e.code == 'invalid-verification-code') {
|
throw const domain.InvalidCredentialsException(
|
||||||
throw const domain.InvalidCredentialsException(
|
technicalMessage: 'Invalid OTP code entered.',
|
||||||
technicalMessage: 'Invalid OTP code entered.',
|
);
|
||||||
);
|
|
||||||
}
|
|
||||||
rethrow;
|
|
||||||
}
|
}
|
||||||
},
|
rethrow;
|
||||||
);
|
}
|
||||||
|
}, requiresAuthentication: false);
|
||||||
final User? firebaseUser = userCredential.user;
|
final User? firebaseUser = userCredential.user;
|
||||||
if (firebaseUser == null) {
|
if (firebaseUser == null) {
|
||||||
throw const domain.SignInFailedException(
|
throw const domain.SignInFailedException(
|
||||||
technicalMessage: 'Phone verification failed, no Firebase user received.',
|
technicalMessage:
|
||||||
|
'Phone verification failed, no Firebase user received.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final QueryResult<GetUserByIdData, GetUserByIdVariables> response =
|
final QueryResult<GetUserByIdData, GetUserByIdVariables> response =
|
||||||
await executeProtected(() => dataConnect
|
await _service.run(
|
||||||
.getUserById(
|
() => _service.connector.getUserById(id: firebaseUser.uid).execute(),
|
||||||
id: firebaseUser.uid,
|
requiresAuthentication: false,
|
||||||
)
|
);
|
||||||
.execute());
|
|
||||||
final GetUserByIdUser? user = response.data.user;
|
final GetUserByIdUser? user = response.data.user;
|
||||||
|
|
||||||
GetStaffByUserIdStaffs? staffRecord;
|
GetStaffByUserIdStaffs? staffRecord;
|
||||||
|
|
||||||
if (mode == AuthMode.signup) {
|
if (mode == AuthMode.signup) {
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
await executeProtected(() => dataConnect
|
await _service.run(
|
||||||
.createUser(
|
() => _service.connector
|
||||||
id: firebaseUser.uid,
|
.createUser(id: firebaseUser.uid, role: UserBaseRole.USER)
|
||||||
role: UserBaseRole.USER,
|
.userRole('STAFF')
|
||||||
)
|
.execute(),
|
||||||
.userRole('STAFF')
|
requiresAuthentication: false,
|
||||||
.execute());
|
);
|
||||||
} else {
|
} else {
|
||||||
if (user.userRole != 'STAFF') {
|
// User exists in PostgreSQL. Check if they have a STAFF profile.
|
||||||
await firebaseAuth.signOut();
|
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
|
||||||
throw const domain.UnauthorizedAppException(
|
staffResponse = await _service.run(
|
||||||
technicalMessage: 'User is not authorized for this app.',
|
() => _service.connector
|
||||||
|
.getStaffByUserId(userId: firebaseUser.uid)
|
||||||
|
.execute(),
|
||||||
|
requiresAuthentication: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (staffResponse.data.staffs.isNotEmpty) {
|
||||||
|
// If profile exists, they should use Login mode.
|
||||||
|
await _service.auth.signOut();
|
||||||
|
throw const domain.AccountExistsException(
|
||||||
|
technicalMessage:
|
||||||
|
'This user already has a staff profile. Please log in.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
|
|
||||||
staffResponse = await executeProtected(() => dataConnect
|
// If they don't have a staff profile but they exist as BUSINESS,
|
||||||
.getStaffByUserId(
|
// they are allowed to "Sign Up" for Staff.
|
||||||
userId: firebaseUser.uid,
|
// We update their userRole to 'BOTH'.
|
||||||
)
|
if (user.userRole == 'BUSINESS') {
|
||||||
.execute());
|
await _service.run(
|
||||||
if (staffResponse.data.staffs.isNotEmpty) {
|
() => _service.connector
|
||||||
await firebaseAuth.signOut();
|
.updateUser(id: firebaseUser.uid)
|
||||||
throw const domain.AccountExistsException(
|
.userRole('BOTH')
|
||||||
technicalMessage: 'This user already has a staff profile. Please log in.',
|
.execute(),
|
||||||
|
requiresAuthentication: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
await firebaseAuth.signOut();
|
await _service.auth.signOut();
|
||||||
throw const domain.UserNotFoundException(
|
throw const domain.UserNotFoundException(
|
||||||
technicalMessage: 'Authenticated user profile not found in database.',
|
technicalMessage: 'Authenticated user profile not found in database.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (user.userRole != 'STAFF') {
|
// Allow STAFF or BOTH roles to log in to the Staff App
|
||||||
await firebaseAuth.signOut();
|
if (user.userRole != 'STAFF' && user.userRole != 'BOTH') {
|
||||||
|
await _service.auth.signOut();
|
||||||
throw const domain.UnauthorizedAppException(
|
throw const domain.UnauthorizedAppException(
|
||||||
technicalMessage: 'User is not authorized for this app.',
|
technicalMessage: 'User is not authorized for this app.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
|
final QueryResult<GetStaffByUserIdData, GetStaffByUserIdVariables>
|
||||||
staffResponse = await executeProtected(() => dataConnect
|
staffResponse = await _service.run(
|
||||||
.getStaffByUserId(
|
() => _service.connector
|
||||||
userId: firebaseUser.uid,
|
.getStaffByUserId(userId: firebaseUser.uid)
|
||||||
)
|
.execute(),
|
||||||
.execute());
|
requiresAuthentication: false,
|
||||||
|
);
|
||||||
if (staffResponse.data.staffs.isEmpty) {
|
if (staffResponse.data.staffs.isEmpty) {
|
||||||
await firebaseAuth.signOut();
|
await _service.auth.signOut();
|
||||||
throw const domain.UserNotFoundException(
|
throw const domain.UserNotFoundException(
|
||||||
technicalMessage: 'Your account is not registered yet. Please register first.',
|
technicalMessage:
|
||||||
|
'Your account is not registered yet. Please register first.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
staffRecord = staffResponse.data.staffs.first;
|
staffRecord = staffResponse.data.staffs.first;
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
|
|
||||||
import '../../domain/repositories/place_repository.dart';
|
import '../../domain/repositories/place_repository.dart';
|
||||||
|
|
||||||
class PlaceRepositoryImpl implements PlaceRepository {
|
class PlaceRepositoryImpl implements PlaceRepository {
|
||||||
@@ -18,7 +20,7 @@ class PlaceRepositoryImpl implements PlaceRepository {
|
|||||||
<String, String>{
|
<String, String>{
|
||||||
'input': query,
|
'input': query,
|
||||||
'types': '(cities)',
|
'types': '(cities)',
|
||||||
'key': AppConfig.googlePlacesApiKey,
|
'key': AppConfig.googleMapsApiKey,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
import 'package:firebase_auth/firebase_auth.dart' as auth;
|
|
||||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
|
||||||
import 'package:krow_domain/krow_domain.dart';
|
import 'package:krow_domain/krow_domain.dart';
|
||||||
|
import 'package:firebase_auth/firebase_auth.dart' as auth;
|
||||||
import '../../domain/repositories/profile_setup_repository.dart';
|
import '../../domain/repositories/profile_setup_repository.dart';
|
||||||
|
|
||||||
class ProfileSetupRepositoryImpl implements ProfileSetupRepository {
|
class ProfileSetupRepositoryImpl implements ProfileSetupRepository {
|
||||||
final auth.FirebaseAuth _firebaseAuth;
|
final DataConnectService _service;
|
||||||
final ExampleConnector _dataConnect;
|
|
||||||
|
|
||||||
ProfileSetupRepositoryImpl({
|
ProfileSetupRepositoryImpl() : _service = DataConnectService.instance;
|
||||||
required auth.FirebaseAuth firebaseAuth,
|
|
||||||
required ExampleConnector dataConnect,
|
|
||||||
}) : _firebaseAuth = firebaseAuth,
|
|
||||||
_dataConnect = dataConnect;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> submitProfile({
|
Future<void> submitProfile({
|
||||||
@@ -23,17 +18,19 @@ class ProfileSetupRepositoryImpl implements ProfileSetupRepository {
|
|||||||
required List<String> industries,
|
required List<String> industries,
|
||||||
required List<String> skills,
|
required List<String> skills,
|
||||||
}) async {
|
}) async {
|
||||||
final auth.User? firebaseUser = _firebaseAuth.currentUser;
|
return _service.run(() async {
|
||||||
if (firebaseUser == null) {
|
final auth.User? firebaseUser = _service.auth.currentUser;
|
||||||
throw Exception('User not authenticated.');
|
if (firebaseUser == null) {
|
||||||
}
|
throw const NotAuthenticatedException(
|
||||||
|
technicalMessage: 'User not authenticated.');
|
||||||
|
}
|
||||||
|
|
||||||
final StaffSession? session = StaffSessionStore.instance.session;
|
final StaffSession? session = StaffSessionStore.instance.session;
|
||||||
final String email = session?.user.email ?? '';
|
final String email = session?.user.email ?? '';
|
||||||
final String? phone = firebaseUser.phoneNumber;
|
final String? phone = firebaseUser.phoneNumber;
|
||||||
|
|
||||||
final fdc.OperationResult<CreateStaffData, CreateStaffVariables>
|
final fdc.OperationResult<CreateStaffData, CreateStaffVariables> result =
|
||||||
result = await _dataConnect
|
await _service.connector
|
||||||
.createStaff(
|
.createStaff(
|
||||||
userId: firebaseUser.uid,
|
userId: firebaseUser.uid,
|
||||||
fullName: fullName,
|
fullName: fullName,
|
||||||
@@ -63,5 +60,6 @@ class ProfileSetupRepositoryImpl implements ProfileSetupRepository {
|
|||||||
StaffSession(user: session.user, staff: staff, ownerId: session.ownerId),
|
StaffSession(user: session.user, staff: staff, ownerId: session.ownerId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,5 +20,4 @@ abstract interface class AuthRepositoryInterface {
|
|||||||
|
|
||||||
/// Signs out the current user.
|
/// Signs out the current user.
|
||||||
Future<void> signOut();
|
Future<void> signOut();
|
||||||
// Future<Staff?> getStaffProfile(String userId); // Could be moved to a separate repository if needed, but useful here for routing logic.
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'package:staff_authentication/src/domain/ui_entities/auth_mode.dart';
|
|||||||
abstract class AuthEvent extends Equatable {
|
abstract class AuthEvent extends Equatable {
|
||||||
const AuthEvent();
|
const AuthEvent();
|
||||||
@override
|
@override
|
||||||
List<Object> get props => <Object>[];
|
List<Object?> get props => <Object?>[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Event for requesting a sign-in with a phone number.
|
/// Event for requesting a sign-in with a phone number.
|
||||||
@@ -19,7 +19,7 @@ class AuthSignInRequested extends AuthEvent {
|
|||||||
const AuthSignInRequested({this.phoneNumber, required this.mode});
|
const AuthSignInRequested({this.phoneNumber, required this.mode});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => <Object>[mode];
|
List<Object?> get props => <Object?>[phoneNumber, mode];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Event for submitting an OTP (One-Time Password) for verification.
|
/// Event for submitting an OTP (One-Time Password) for verification.
|
||||||
@@ -43,7 +43,7 @@ class AuthOtpSubmitted extends AuthEvent {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => <Object>[verificationId, smsCode, mode];
|
List<Object?> get props => <Object?>[verificationId, smsCode, mode];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Event for clearing any authentication error in the state.
|
/// Event for clearing any authentication error in the state.
|
||||||
@@ -57,7 +57,7 @@ class AuthResetRequested extends AuthEvent {
|
|||||||
const AuthResetRequested({required this.mode});
|
const AuthResetRequested({required this.mode});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => <Object>[mode];
|
List<Object?> get props => <Object?>[mode];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Event for ticking down the resend cooldown.
|
/// Event for ticking down the resend cooldown.
|
||||||
@@ -67,7 +67,7 @@ class AuthCooldownTicked extends AuthEvent {
|
|||||||
const AuthCooldownTicked(this.secondsRemaining);
|
const AuthCooldownTicked(this.secondsRemaining);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => <Object>[secondsRemaining];
|
List<Object?> get props => <Object?>[secondsRemaining];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Event for updating the current draft OTP in the state.
|
/// Event for updating the current draft OTP in the state.
|
||||||
@@ -78,7 +78,7 @@ class AuthOtpUpdated extends AuthEvent {
|
|||||||
const AuthOtpUpdated(this.otp);
|
const AuthOtpUpdated(this.otp);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => <Object>[otp];
|
List<Object?> get props => <Object?>[otp];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Event for updating the current draft phone number in the state.
|
/// Event for updating the current draft phone number in the state.
|
||||||
@@ -89,5 +89,5 @@ class AuthPhoneUpdated extends AuthEvent {
|
|||||||
const AuthPhoneUpdated(this.phoneNumber);
|
const AuthPhoneUpdated(this.phoneNumber);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<Object> get props => <Object>[phoneNumber];
|
List<Object?> get props => <Object?>[phoneNumber];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,17 +50,22 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
|
|||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required String phoneNumber,
|
required String phoneNumber,
|
||||||
}) {
|
}) {
|
||||||
final String normalized = phoneNumber.replaceAll(RegExp(r'\\D'), '');
|
String normalized = phoneNumber.replaceAll(RegExp(r'\D'), '');
|
||||||
|
|
||||||
|
// Handle US numbers entered with a leading 1
|
||||||
|
if (normalized.length == 11 && normalized.startsWith('1')) {
|
||||||
|
normalized = normalized.substring(1);
|
||||||
|
}
|
||||||
|
|
||||||
if (normalized.length == 10) {
|
if (normalized.length == 10) {
|
||||||
BlocProvider.of<AuthBloc>(
|
BlocProvider.of<AuthBloc>(context).add(
|
||||||
context,
|
|
||||||
).add(
|
|
||||||
AuthSignInRequested(phoneNumber: '+1$normalized', mode: widget.mode),
|
AuthSignInRequested(phoneNumber: '+1$normalized', mode: widget.mode),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
UiSnackbar.show(
|
UiSnackbar.show(
|
||||||
context,
|
context,
|
||||||
message: t.staff_authentication.phone_verification_page.validation_error,
|
message:
|
||||||
|
t.staff_authentication.phone_verification_page.validation_error,
|
||||||
type: UiSnackbarType.error,
|
type: UiSnackbarType.error,
|
||||||
margin: const EdgeInsets.only(bottom: 180, left: 16, right: 16),
|
margin: const EdgeInsets.only(bottom: 180, left: 16, right: 16),
|
||||||
);
|
);
|
||||||
@@ -73,9 +78,7 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
|
|||||||
required String otp,
|
required String otp,
|
||||||
required String verificationId,
|
required String verificationId,
|
||||||
}) {
|
}) {
|
||||||
BlocProvider.of<AuthBloc>(
|
BlocProvider.of<AuthBloc>(context).add(
|
||||||
context,
|
|
||||||
).add(
|
|
||||||
AuthOtpSubmitted(
|
AuthOtpSubmitted(
|
||||||
verificationId: verificationId,
|
verificationId: verificationId,
|
||||||
smsCode: otp,
|
smsCode: otp,
|
||||||
@@ -86,9 +89,9 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
|
|||||||
|
|
||||||
/// Handles the request to resend the verification code using the phone number in the state.
|
/// Handles the request to resend the verification code using the phone number in the state.
|
||||||
void _onResend({required BuildContext context}) {
|
void _onResend({required BuildContext context}) {
|
||||||
BlocProvider.of<AuthBloc>(context).add(
|
BlocProvider.of<AuthBloc>(
|
||||||
AuthSignInRequested(mode: widget.mode),
|
context,
|
||||||
);
|
).add(AuthSignInRequested(mode: widget.mode));
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -102,8 +105,6 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
|
|||||||
if (state.status == AuthStatus.authenticated) {
|
if (state.status == AuthStatus.authenticated) {
|
||||||
if (state.mode == AuthMode.signup) {
|
if (state.mode == AuthMode.signup) {
|
||||||
Modular.to.toProfileSetup();
|
Modular.to.toProfileSetup();
|
||||||
} else {
|
|
||||||
Modular.to.toStaffHome();
|
|
||||||
}
|
}
|
||||||
} else if (state.status == AuthStatus.error &&
|
} else if (state.status == AuthStatus.error &&
|
||||||
state.mode == AuthMode.signup) {
|
state.mode == AuthMode.signup) {
|
||||||
@@ -114,7 +115,11 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
|
|||||||
context,
|
context,
|
||||||
message: translateErrorKey(messageKey),
|
message: translateErrorKey(messageKey),
|
||||||
type: UiSnackbarType.error,
|
type: UiSnackbarType.error,
|
||||||
margin: const EdgeInsets.only(bottom: 180, left: 16, right: 16),
|
margin: const EdgeInsets.only(
|
||||||
|
bottom: 180,
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
Future<void>.delayed(const Duration(seconds: 5), () {
|
Future<void>.delayed(const Duration(seconds: 5), () {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -147,9 +152,9 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
|
|||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
showBackButton: true,
|
showBackButton: true,
|
||||||
onLeadingPressed: () {
|
onLeadingPressed: () {
|
||||||
BlocProvider.of<AuthBloc>(context).add(
|
BlocProvider.of<AuthBloc>(
|
||||||
AuthResetRequested(mode: widget.mode),
|
context,
|
||||||
);
|
).add(AuthResetRequested(mode: widget.mode));
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -169,13 +174,13 @@ class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
|
|||||||
verificationId: state.verificationId ?? '',
|
verificationId: state.verificationId ?? '',
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: PhoneInput(
|
: PhoneInput(
|
||||||
state: state,
|
state: state,
|
||||||
onSendCode: (String phoneNumber) => _onSendCode(
|
onSendCode: (String phoneNumber) => _onSendCode(
|
||||||
context: context,
|
context: context,
|
||||||
phoneNumber: phoneNumber,
|
phoneNumber: phoneNumber,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ class _PhoneInputFormFieldState extends State<PhoneInputFormField> {
|
|||||||
keyboardType: TextInputType.phone,
|
keyboardType: TextInputType.phone,
|
||||||
inputFormatters: <TextInputFormatter>[
|
inputFormatters: <TextInputFormatter>[
|
||||||
FilteringTextInputFormatter.digitsOnly,
|
FilteringTextInputFormatter.digitsOnly,
|
||||||
LengthLimitingTextInputFormatter(10),
|
LengthLimitingTextInputFormatter(11),
|
||||||
],
|
],
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: t.staff_authentication.phone_input.hint,
|
hintText: t.staff_authentication.phone_input.hint,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_modular/flutter_modular.dart';
|
import 'package:flutter_modular/flutter_modular.dart';
|
||||||
import 'package:krow_core/core.dart';
|
import 'package:krow_core/core.dart';
|
||||||
import 'package:krow_data_connect/krow_data_connect.dart';
|
import 'package:krow_data_connect/krow_data_connect.dart';
|
||||||
import 'package:firebase_auth/firebase_auth.dart' as firebase;
|
|
||||||
import 'package:staff_authentication/src/data/repositories_impl/auth_repository_impl.dart';
|
import 'package:staff_authentication/src/data/repositories_impl/auth_repository_impl.dart';
|
||||||
import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart';
|
import 'package:staff_authentication/src/domain/repositories/auth_repository_interface.dart';
|
||||||
import 'package:staff_authentication/src/domain/usecases/sign_in_with_phone_usecase.dart';
|
import 'package:staff_authentication/src/domain/usecases/sign_in_with_phone_usecase.dart';
|
||||||
@@ -15,6 +14,7 @@ import 'package:staff_authentication/src/data/repositories_impl/place_repository
|
|||||||
import 'package:staff_authentication/src/domain/usecases/search_cities_usecase.dart';
|
import 'package:staff_authentication/src/domain/usecases/search_cities_usecase.dart';
|
||||||
import 'package:staff_authentication/src/presentation/blocs/auth_bloc.dart';
|
import 'package:staff_authentication/src/presentation/blocs/auth_bloc.dart';
|
||||||
import 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_bloc.dart';
|
import 'package:staff_authentication/src/presentation/blocs/profile_setup/profile_setup_bloc.dart';
|
||||||
|
import 'package:staff_authentication/src/presentation/pages/intro_page.dart';
|
||||||
import 'package:staff_authentication/src/presentation/pages/get_started_page.dart';
|
import 'package:staff_authentication/src/presentation/pages/get_started_page.dart';
|
||||||
import 'package:staff_authentication/src/presentation/pages/phone_verification_page.dart';
|
import 'package:staff_authentication/src/presentation/pages/phone_verification_page.dart';
|
||||||
import 'package:staff_authentication/src/presentation/pages/profile_setup_page.dart';
|
import 'package:staff_authentication/src/presentation/pages/profile_setup_page.dart';
|
||||||
@@ -28,18 +28,8 @@ class StaffAuthenticationModule extends Module {
|
|||||||
@override
|
@override
|
||||||
void binds(Injector i) {
|
void binds(Injector i) {
|
||||||
// Repositories
|
// Repositories
|
||||||
i.addLazySingleton<AuthRepositoryInterface>(
|
i.addLazySingleton<AuthRepositoryInterface>(AuthRepositoryImpl.new);
|
||||||
() => AuthRepositoryImpl(
|
i.addLazySingleton<ProfileSetupRepository>(ProfileSetupRepositoryImpl.new);
|
||||||
firebaseAuth: firebase.FirebaseAuth.instance,
|
|
||||||
dataConnect: ExampleConnector.instance,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
i.addLazySingleton<ProfileSetupRepository>(
|
|
||||||
() => ProfileSetupRepositoryImpl(
|
|
||||||
firebaseAuth: firebase.FirebaseAuth.instance,
|
|
||||||
dataConnect: ExampleConnector.instance,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
i.addLazySingleton<PlaceRepository>(PlaceRepositoryImpl.new);
|
i.addLazySingleton<PlaceRepository>(PlaceRepositoryImpl.new);
|
||||||
|
|
||||||
// UseCases
|
// UseCases
|
||||||
@@ -65,7 +55,8 @@ class StaffAuthenticationModule extends Module {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void routes(RouteManager r) {
|
void routes(RouteManager r) {
|
||||||
r.child(StaffPaths.root, child: (_) => const GetStartedPage());
|
r.child(StaffPaths.root, child: (_) => const IntroPage());
|
||||||
|
r.child(StaffPaths.getStarted, child: (_) => const GetStartedPage());
|
||||||
r.child(
|
r.child(
|
||||||
StaffPaths.phoneVerification,
|
StaffPaths.phoneVerification,
|
||||||
child: (BuildContext context) {
|
child: (BuildContext context) {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user