Merge branch '312-feature-integrate-google-maps-places-autocomplete-for-hub-address-validation' into fix_staff_app_bugs

This commit is contained in:
José Salazar
2026-02-02 22:33:10 +09:00
50 changed files with 2315 additions and 884 deletions

View File

@@ -46,4 +46,12 @@
- Staff APP:
- On app launch, check whether there is an active session. If a valid session exists, skip the auth flow and navigate directly to Home, loading Staff account.
- Add an expiration time (TTL) to the session (store expiresAt / expiryTimestamp) and invalidate/clear the session when it has expired.
- For staffs Skills = Roles? thinking in the future for the smart assigned that need to know the roles of staff to assign.
- For staffs Skills = Roles? thinking in the future for the smart assigned that need to know the roles of staff to assign.
## App
- Staff Application
### Github issue
- https://github.com/Oloodi/krow-workforce/issues/248
### Deveations:
- Assumed that a worker can only have one shift per day.

View File

@@ -5,42 +5,6 @@
"storage_bucket": "krow-workforce-dev.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:933560802882:android:87d41566f8dda41d7757db",
"android_client_info": {
"package_name": "com.example.krow_workforce"
}
},
"oauth_client": [
{
"client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyDBYhflhK6DThKnS7RM-9raKdvyKzLUjY4"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com",
"client_type": 3
},
{
"client_id": "933560802882-dppsapp5i3lsfrlm1mhob2s21peofg1t.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "com.krow.app.staff.dev"
}
}
]
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:933560802882:android:edcddb83ea4bbb517757db",
@@ -67,10 +31,10 @@
"client_type": 3
},
{
"client_id": "933560802882-dppsapp5i3lsfrlm1mhob2s21peofg1t.apps.googleusercontent.com",
"client_id": "933560802882-29olj9ku64jbe9h7flinha6hbi8qrluh.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "com.krow.app.staff.dev"
"bundle_id": "com.krowwithus.staff"
}
}
]
@@ -103,10 +67,10 @@
"client_type": 3
},
{
"client_id": "933560802882-dppsapp5i3lsfrlm1mhob2s21peofg1t.apps.googleusercontent.com",
"client_id": "933560802882-29olj9ku64jbe9h7flinha6hbi8qrluh.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "com.krow.app.staff.dev"
"bundle_id": "com.krowwithus.staff"
}
}
]
@@ -139,10 +103,10 @@
"client_type": 3
},
{
"client_id": "933560802882-dppsapp5i3lsfrlm1mhob2s21peofg1t.apps.googleusercontent.com",
"client_id": "933560802882-29olj9ku64jbe9h7flinha6hbi8qrluh.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "com.krow.app.staff.dev"
"bundle_id": "com.krowwithus.staff"
}
}
]
@@ -151,12 +115,20 @@
},
{
"client_info": {
"mobilesdk_app_id": "1:933560802882:android:d26bde4ee337b0b17757db",
"mobilesdk_app_id": "1:933560802882:android:1ae05d85c865f77c7757db",
"android_client_info": {
"package_name": "com.krowwithus.krow_workforce.dev"
"package_name": "com.krowwithus.staff"
}
},
"oauth_client": [
{
"client_id": "933560802882-ikdfv3o5f47g36qqgvfq55o4m19n7gk4.apps.googleusercontent.com",
"client_type": 1,
"android_info": {
"package_name": "com.krowwithus.staff",
"certificate_hash": "ac917ae8470ab29f1107c773c6017ff5ea5d102d"
}
},
{
"client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com",
"client_type": 3
@@ -175,10 +147,10 @@
"client_type": 3
},
{
"client_id": "933560802882-dppsapp5i3lsfrlm1mhob2s21peofg1t.apps.googleusercontent.com",
"client_id": "933560802882-29olj9ku64jbe9h7flinha6hbi8qrluh.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "com.krow.app.staff.dev"
"bundle_id": "com.krowwithus.staff"
}
}
]

View File

@@ -14,6 +14,7 @@
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
E8C1A28BFABAEE32FB779C9A /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 221E00B70DE845BE3D50D0A0 /* GoogleService-Info.plist */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -42,6 +43,7 @@
/* Begin PBXFileReference section */
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
221E00B70DE845BE3D50D0A0 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
@@ -94,6 +96,7 @@
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
221E00B70DE845BE3D50D0A0 /* GoogleService-Info.plist */,
);
sourceTree = "<group>";
};
@@ -216,6 +219,7 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
E8C1A28BFABAEE32FB779C9A /* GoogleService-Info.plist in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CLIENT_ID</key>
<string>933560802882-jqpv1l3gjmi3m87b2gu1iq4lg46lkdfg.apps.googleusercontent.com</string>
<key>REVERSED_CLIENT_ID</key>
<string>com.googleusercontent.apps.933560802882-jqpv1l3gjmi3m87b2gu1iq4lg46lkdfg</string>
<key>ANDROID_CLIENT_ID</key>
<string>933560802882-ikdfv3o5f47g36qqgvfq55o4m19n7gk4.apps.googleusercontent.com</string>
<key>API_KEY</key>
<string>AIzaSyDyEXkzZAWpXXe4dAesYaZflt5BEtMn9tA</string>
<key>GCM_SENDER_ID</key>
<string>933560802882</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.krowwithus.client</string>
<key>PROJECT_ID</key>
<string>krow-workforce-dev</string>
<key>STORAGE_BUCKET</key>
<string>krow-workforce-dev.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:933560802882:ios:d2b6d743608e2a527757db</string>
</dict>
</plist>

View File

@@ -0,0 +1,74 @@
// File generated by FlutterFire CLI.
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
/// Default [FirebaseOptions] for use with your Firebase apps.
///
/// Example:
/// ```dart
/// import 'firebase_options.dart';
/// // ...
/// await Firebase.initializeApp(
/// options: DefaultFirebaseOptions.currentPlatform,
/// );
/// ```
class DefaultFirebaseOptions {
static FirebaseOptions get currentPlatform {
if (kIsWeb) {
return web;
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return android;
case TargetPlatform.iOS:
return ios;
case TargetPlatform.macOS:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for macos - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
case TargetPlatform.windows:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for windows - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
case TargetPlatform.linux:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for linux - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
default:
throw UnsupportedError(
'DefaultFirebaseOptions are not supported for this platform.',
);
}
}
static const FirebaseOptions web = FirebaseOptions(
apiKey: 'AIzaSyBqRtZPMGU-Sz5x5UnRrunKu5NSWYyPRn8',
appId: '1:933560802882:web:173a841992885bb27757db',
messagingSenderId: '933560802882',
projectId: 'krow-workforce-dev',
authDomain: 'krow-workforce-dev.firebaseapp.com',
storageBucket: 'krow-workforce-dev.firebasestorage.app',
);
static const FirebaseOptions android = FirebaseOptions(
apiKey: 'AIzaSyDBYhflhK6DThKnS7RM-9raKdvyKzLUjY4',
appId: '1:933560802882:android:da13569105659ead7757db',
messagingSenderId: '933560802882',
projectId: 'krow-workforce-dev',
storageBucket: 'krow-workforce-dev.firebasestorage.app',
);
static const FirebaseOptions ios = FirebaseOptions(
apiKey: 'AIzaSyDyEXkzZAWpXXe4dAesYaZflt5BEtMn9tA',
appId: '1:933560802882:ios:d2b6d743608e2a527757db',
messagingSenderId: '933560802882',
projectId: 'krow-workforce-dev',
storageBucket: 'krow-workforce-dev.firebasestorage.app',
iosBundleId: 'com.krowwithus.client',
);
}

View File

@@ -12,11 +12,15 @@ import 'package:client_hubs/client_hubs.dart' as client_hubs;
import 'package:client_create_order/client_create_order.dart'
as client_create_order;
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/foundation.dart';
import 'package:krow_core/core.dart';
import 'firebase_options.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
await Firebase.initializeApp(
options: kIsWeb ? DefaultFirebaseOptions.currentPlatform : null,
);
runApp(ModularApp(module: AppModule(), child: const AppWidget()));
}

View File

@@ -32,6 +32,8 @@ dependencies:
path: ../../packages/features/client/hubs
client_create_order:
path: ../../packages/features/client/create_order
krow_core:
path: ../../packages/core
cupertino_icons: ^1.0.8
flutter_modular: ^6.3.2

View File

@@ -33,6 +33,29 @@
<link rel="manifest" href="manifest.json">
</head>
<body>
<script type="module">
// Import the functions you need from the SDKs you need
import { initializeApp } from "https://www.gstatic.com/firebasejs/12.8.0/firebase-app.js";
import { getAnalytics } from "https://www.gstatic.com/firebasejs/12.8.0/firebase-analytics.js";
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries
// Your web app's Firebase configuration
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
const firebaseConfig = {
apiKey: "AIzaSyBqRtZPMGU-Sz5x5UnRrunKu5NSWYyPRn8",
authDomain: "krow-workforce-dev.firebaseapp.com",
projectId: "krow-workforce-dev",
storageBucket: "krow-workforce-dev.firebasestorage.app",
messagingSenderId: "933560802882",
appId: "1:933560802882:web:173a841992885bb27757db",
measurementId: "G-9S7WEQTDKX"
};
// Initialize Firebase
const app = initializeApp(firebaseConfig);
const analytics = getAnalytics(app);
</script>
<script src="flutter_bootstrap.js" async></script>
</body>
</html>

View File

@@ -5,42 +5,6 @@
"storage_bucket": "krow-workforce-dev.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:933560802882:android:87d41566f8dda41d7757db",
"android_client_info": {
"package_name": "com.example.krow_workforce"
}
},
"oauth_client": [
{
"client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyDBYhflhK6DThKnS7RM-9raKdvyKzLUjY4"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com",
"client_type": 3
},
{
"client_id": "933560802882-dppsapp5i3lsfrlm1mhob2s21peofg1t.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "com.krow.app.staff.dev"
}
}
]
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:933560802882:android:edcddb83ea4bbb517757db",
@@ -149,42 +113,6 @@
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:933560802882:android:d26bde4ee337b0b17757db",
"android_client_info": {
"package_name": "com.krowwithus.krow_workforce.dev"
}
},
"oauth_client": [
{
"client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com",
"client_type": 3
}
],
"api_key": [
{
"current_key": "AIzaSyDBYhflhK6DThKnS7RM-9raKdvyKzLUjY4"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": [
{
"client_id": "933560802882-grp98a1v7amflnnup68vh01tj06eaem1.apps.googleusercontent.com",
"client_type": 3
},
{
"client_id": "933560802882-dppsapp5i3lsfrlm1mhob2s21peofg1t.apps.googleusercontent.com",
"client_type": 2,
"ios_info": {
"bundle_id": "com.krow.app.staff.dev"
}
}
]
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:933560802882:android:1ae05d85c865f77c7757db",

View File

@@ -8,6 +8,7 @@
/* Begin PBXBuildFile section */
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
1E967D034ADA3A16EF82CB3E /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 9F0B07DEC91B141354438F79 /* GoogleService-Info.plist */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
@@ -55,6 +56,7 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
9F0B07DEC91B141354438F79 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -94,6 +96,7 @@
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
9F0B07DEC91B141354438F79 /* GoogleService-Info.plist */,
);
sourceTree = "<group>";
};
@@ -216,6 +219,7 @@
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
1E967D034ADA3A16EF82CB3E /* GoogleService-Info.plist in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CLIENT_ID</key>
<string>933560802882-29olj9ku64jbe9h7flinha6hbi8qrluh.apps.googleusercontent.com</string>
<key>REVERSED_CLIENT_ID</key>
<string>com.googleusercontent.apps.933560802882-29olj9ku64jbe9h7flinha6hbi8qrluh</string>
<key>ANDROID_CLIENT_ID</key>
<string>933560802882-ikdfv3o5f47g36qqgvfq55o4m19n7gk4.apps.googleusercontent.com</string>
<key>API_KEY</key>
<string>AIzaSyDyEXkzZAWpXXe4dAesYaZflt5BEtMn9tA</string>
<key>GCM_SENDER_ID</key>
<string>933560802882</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.krowwithus.staff</string>
<key>PROJECT_ID</key>
<string>krow-workforce-dev</string>
<key>STORAGE_BUCKET</key>
<string>krow-workforce-dev.firebasestorage.app</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:933560802882:ios:fa584205b356de937757db</string>
</dict>
</plist>

View File

@@ -0,0 +1,74 @@
// File generated by FlutterFire CLI.
import 'package:firebase_core/firebase_core.dart' show FirebaseOptions;
import 'package:flutter/foundation.dart'
show defaultTargetPlatform, kIsWeb, TargetPlatform;
/// Default [FirebaseOptions] for use with your Firebase apps.
///
/// Example:
/// ```dart
/// import 'firebase_options.dart';
/// // ...
/// await Firebase.initializeApp(
/// options: DefaultFirebaseOptions.currentPlatform,
/// );
/// ```
class DefaultFirebaseOptions {
static FirebaseOptions get currentPlatform {
if (kIsWeb) {
return web;
}
switch (defaultTargetPlatform) {
case TargetPlatform.android:
return android;
case TargetPlatform.iOS:
return ios;
case TargetPlatform.macOS:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for macos - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
case TargetPlatform.windows:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for windows - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
case TargetPlatform.linux:
throw UnsupportedError(
'DefaultFirebaseOptions have not been configured for linux - '
'you can reconfigure this by running the FlutterFire CLI again.',
);
default:
throw UnsupportedError(
'DefaultFirebaseOptions are not supported for this platform.',
);
}
}
static const FirebaseOptions web = FirebaseOptions(
apiKey: 'AIzaSyBqRtZPMGU-Sz5x5UnRrunKu5NSWYyPRn8',
appId: '1:933560802882:web:4508ef1ee6d4e6907757db',
messagingSenderId: '933560802882',
projectId: 'krow-workforce-dev',
authDomain: 'krow-workforce-dev.firebaseapp.com',
storageBucket: 'krow-workforce-dev.firebasestorage.app',
);
static const FirebaseOptions android = FirebaseOptions(
apiKey: 'AIzaSyDBYhflhK6DThKnS7RM-9raKdvyKzLUjY4',
appId: '1:933560802882:android:d49b8c0f4d19e95e7757db',
messagingSenderId: '933560802882',
projectId: 'krow-workforce-dev',
storageBucket: 'krow-workforce-dev.firebasestorage.app',
);
static const FirebaseOptions ios = FirebaseOptions(
apiKey: 'AIzaSyDyEXkzZAWpXXe4dAesYaZflt5BEtMn9tA',
appId: '1:933560802882:ios:fa584205b356de937757db',
messagingSenderId: '933560802882',
projectId: 'krow-workforce-dev',
storageBucket: 'krow-workforce-dev.firebasestorage.app',
iosBundleId: 'com.krowwithus.staff',
);
}

View File

@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:krowwithus_staff/firebase_options.dart';
import 'package:staff_authentication/staff_authentication.dart'
as staff_authentication;
import 'package:staff_main/staff_main.dart' as staff_main;
@@ -12,7 +13,9 @@ import 'package:krow_core/core.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp();
await Firebase.initializeApp(
options: DefaultFirebaseOptions.currentPlatform,
);
runApp(ModularApp(module: AppModule(), child: const AppWidget()));
}

View File

@@ -11,10 +11,7 @@ dependencies:
sdk: flutter
flutter_localizations:
sdk: flutter
cupertino_icons: ^1.0.8
flutter_modular: ^6.3.0
# Architecture Packages
# Architecture Packages
design_system:
path: ../../packages/design_system
core_localization:
@@ -27,6 +24,14 @@ dependencies:
path: ../../packages/features/staff/availability
staff_clock_in:
path: ../../packages/features/staff/clock_in
staff_main:
path: ../../packages/features/staff/staff_main
krow_core:
path: ../../packages/core
cupertino_icons: ^1.0.8
flutter_modular: ^6.3.0
firebase_core: ^4.4.0
flutter_bloc: ^8.1.6
dev_dependencies:
flutter_test:

View File

@@ -34,5 +34,28 @@
</head>
<body>
<script src="flutter_bootstrap.js" async></script>
<script type="module">
// Import the functions you need from the SDKs you need
import { initializeApp } from "https://www.gstatic.com/firebasejs/12.8.0/firebase-app.js";
import { getAnalytics } from "https://www.gstatic.com/firebasejs/12.8.0/firebase-analytics.js";
// TODO: Add SDKs for Firebase products that you want to use
// https://firebase.google.com/docs/web/setup#available-libraries
// Your web app's Firebase configuration
// For Firebase JS SDK v7.20.0 and later, measurementId is optional
const firebaseConfig = {
apiKey: "AIzaSyBqRtZPMGU-Sz5x5UnRrunKu5NSWYyPRn8",
authDomain: "krow-workforce-dev.firebaseapp.com",
projectId: "krow-workforce-dev",
storageBucket: "krow-workforce-dev.firebasestorage.app",
messagingSenderId: "933560802882",
appId: "1:933560802882:web:4508ef1ee6d4e6907757db",
measurementId: "G-DTDL7YRRM6"
};
// Initialize Firebase
const app = initializeApp(firebaseConfig);
const analytics = getAnalytics(app);
</script>
</body>
</html>

View File

@@ -1,13 +1,11 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// A wrapper widget that renders the application inside an iPhone-like frame
/// specifically for Flutter Web. On other platforms, it simply returns the child.
class WebMobileFrame extends StatelessWidget {
final Widget child;
final Widget logo;
final String appName;
const WebMobileFrame({
super.key,
required this.child,
@@ -15,6 +13,10 @@ class WebMobileFrame extends StatelessWidget {
required this.appName,
});
final Widget child;
final Widget logo;
final String appName;
@override
Widget build(BuildContext context) {
if (!kIsWeb) return child;
@@ -22,26 +24,22 @@ class WebMobileFrame extends StatelessWidget {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: ThemeData.dark(),
home: _WebFrameContent(
logo: logo,
appName: appName,
child: child,
),
home: _WebFrameContent(logo: logo, appName: appName, child: child),
);
}
}
class _WebFrameContent extends StatefulWidget {
final Widget child;
final Widget logo;
final String appName;
const _WebFrameContent({
required this.child,
required this.logo,
required this.appName,
});
final Widget child;
final Widget logo;
final String appName;
@override
State<_WebFrameContent> createState() => _WebFrameContentState();
}
@@ -61,10 +59,10 @@ class _WebFrameContentState extends State<_WebFrameContent> {
const double borderThickness = 12.0;
return Scaffold(
backgroundColor: const Color(0xFF121212),
backgroundColor: UiColors.foreground,
body: MouseRegion(
cursor: SystemMouseCursors.none,
onHover: (event) {
onHover: (PointerHoverEvent event) {
setState(() {
_cursorPosition = event.position;
_isHovering = true;
@@ -72,7 +70,7 @@ class _WebFrameContentState extends State<_WebFrameContent> {
},
onExit: (_) => setState(() => _isHovering = false),
child: Stack(
children: [
children: <Widget>[
// Logo and Title on the left (Web only)
Positioned(
left: 60,
@@ -84,28 +82,21 @@ class _WebFrameContentState extends State<_WebFrameContent> {
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 140,
child: widget.logo,
),
children: <Widget>[
SizedBox(width: 140, child: widget.logo),
const SizedBox(height: 12),
Text(
widget.appName,
textAlign: TextAlign.left,
style: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
fontFamily: 'Instrument Sans', // Fallback if available or system
style: UiTypography.display1b.copyWith(
color: UiColors.white,
),
),
const SizedBox(height: 4),
Container(
height: 2,
width: 40,
color: Colors.white.withOpacity(0.3),
color: UiColors.white.withOpacity(0.3),
),
],
),
@@ -116,11 +107,11 @@ class _WebFrameContentState extends State<_WebFrameContent> {
// Frame and Content
Center(
child: LayoutBuilder(
builder: (context, constraints) {
builder: (BuildContext context, BoxConstraints constraints) {
// Scale down if screen is too small
double scaleX = constraints.maxWidth / (frameWidth + 80);
double scaleY = constraints.maxHeight / (frameHeight + 80);
double scale = (scaleX < 1 || scaleY < 1)
final double scaleX = constraints.maxWidth / (frameWidth - 150);
final double scaleY = constraints.maxHeight / (frameHeight - 220);
final double scale = (scaleX < 1 || scaleY < 1)
? (scaleX < scaleY ? scaleX : scaleY)
: 1.0;
@@ -130,11 +121,11 @@ class _WebFrameContentState extends State<_WebFrameContent> {
width: frameWidth,
height: frameHeight,
decoration: BoxDecoration(
color: Colors.black,
color: UiColors.black,
borderRadius: BorderRadius.circular(borderRadius),
boxShadow: [
boxShadow: <BoxShadow>[
BoxShadow(
color: Colors.black.withOpacity(0.6),
color: UiColors.black.withOpacity(0.6),
blurRadius: 40,
spreadRadius: 10,
),
@@ -149,10 +140,10 @@ class _WebFrameContentState extends State<_WebFrameContent> {
borderRadius - borderThickness,
),
child: Stack(
children: [
children: <Widget>[
// The actual app + status bar
Column(
children: [
children: <Widget>[
// Mock iOS Status Bar
Container(
height: 48,
@@ -160,26 +151,26 @@ class _WebFrameContentState extends State<_WebFrameContent> {
horizontal: 24,
),
decoration: const BoxDecoration(
color: Color(0xFFF9F6EE),
color: UiColors.background,
border: Border(
bottom: BorderSide(
color: Color(0xFFEEEEEE),
color: UiColors.border,
width: 0.5,
),
),
),
child: Row(
child: const Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
children: <Widget>[
// Time side
const SizedBox(
SizedBox(
width: 80,
child: Text(
'9:41 PM',
textAlign: TextAlign.center,
style: TextStyle(
color: Colors.black54,
color: UiColors.black,
fontWeight: FontWeight.w700,
fontSize: 14,
letterSpacing: -0.2,
@@ -187,27 +178,27 @@ class _WebFrameContentState extends State<_WebFrameContent> {
),
),
// Status Icons side
const SizedBox(
SizedBox(
width: 80,
child: Row(
mainAxisAlignment:
MainAxisAlignment.end,
spacing: 12,
children: [
children: <Widget>[
Icon(
Icons.signal_cellular_alt,
size: 14,
color: Colors.black54,
color: UiColors.black,
),
Icon(
Icons.wifi,
size: 14,
color: Colors.black54,
color: UiColors.black,
),
Icon(
Icons.battery_full,
size: 14,
color: Colors.black54,
color: UiColors.black,
),
],
),
@@ -215,7 +206,7 @@ class _WebFrameContentState extends State<_WebFrameContent> {
],
),
),
// The main app content content
// The main app content
Expanded(child: widget.child),
],
),
@@ -228,7 +219,7 @@ class _WebFrameContentState extends State<_WebFrameContent> {
height: 35,
margin: const EdgeInsets.only(top: 10),
decoration: BoxDecoration(
color: Colors.black,
color: UiColors.black,
borderRadius: BorderRadius.circular(20),
),
),
@@ -241,6 +232,29 @@ class _WebFrameContentState extends State<_WebFrameContent> {
},
),
),
if (_isHovering)
Positioned(
left: _cursorPosition.dx - 15,
top: _cursorPosition.dy - 15,
child: IgnorePointer(
child: Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: UiColors.mutedForeground.withOpacity(0.3),
shape: BoxShape.circle,
border: Border.all(color: UiColors.white.withOpacity(0.7), width: 2),
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withOpacity(0.2),
blurRadius: 4,
spreadRadius: 1,
),
],
),
),
),
),
],
),
),

View File

@@ -11,3 +11,5 @@ environment:
dependencies:
flutter:
sdk: flutter
design_system:
path: ../design_system

View File

@@ -63,6 +63,9 @@ class UiIcons {
/// Checkmark icon
static const IconData check = _IconLib.check;
/// Checkmark circle icon
static const IconData checkCircle = _IconLib.checkCircle;
/// X/Cancel icon
static const IconData close = _IconLib.x;

View File

@@ -1,7 +1,8 @@
import 'package:flutter/material.dart';
import 'ui_colors.dart';
import 'ui_typography.dart';
import 'ui_constants.dart';
import 'ui_typography.dart';
/// The main entry point for the Staff Design System theme.
/// Assembles colors, typography, and constants into a comprehensive Material 3 theme.

View File

@@ -50,7 +50,7 @@ class UiButton extends StatelessWidget {
this.trailingIcon,
this.style,
this.iconSize = 20,
this.size = UiButtonSize.medium,
this.size = UiButtonSize.large,
this.fullWidth = false,
}) : assert(
text != null || child != null,
@@ -67,7 +67,7 @@ class UiButton extends StatelessWidget {
this.trailingIcon,
this.style,
this.iconSize = 20,
this.size = UiButtonSize.medium,
this.size = UiButtonSize.large,
this.fullWidth = false,
}) : buttonBuilder = _elevatedButtonBuilder,
assert(
@@ -85,7 +85,7 @@ class UiButton extends StatelessWidget {
this.trailingIcon,
this.style,
this.iconSize = 20,
this.size = UiButtonSize.medium,
this.size = UiButtonSize.large,
this.fullWidth = false,
}) : buttonBuilder = _outlinedButtonBuilder,
assert(
@@ -103,7 +103,7 @@ class UiButton extends StatelessWidget {
this.trailingIcon,
this.style,
this.iconSize = 20,
this.size = UiButtonSize.medium,
this.size = UiButtonSize.large,
this.fullWidth = false,
}) : buttonBuilder = _textButtonBuilder,
assert(
@@ -121,7 +121,7 @@ class UiButton extends StatelessWidget {
this.trailingIcon,
this.style,
this.iconSize = 20,
this.size = UiButtonSize.medium,
this.size = UiButtonSize.large,
this.fullWidth = false,
}) : buttonBuilder = _textButtonBuilder,
assert(
@@ -132,10 +132,14 @@ class UiButton extends StatelessWidget {
@override
/// Builds the button UI.
Widget build(BuildContext context) {
final ButtonStyle? mergedStyle = style != null
? _getSizeStyle().merge(style)
: _getSizeStyle();
final Widget button = buttonBuilder(
context,
onPressed,
style,
mergedStyle,
_buildButtonContent(),
);
@@ -146,6 +150,65 @@ class UiButton extends StatelessWidget {
return button;
}
/// Gets the style based on the button size.
ButtonStyle _getSizeStyle() {
switch (size) {
case UiButtonSize.extraSmall:
return ButtonStyle(
padding: WidgetStateProperty.all(
const EdgeInsets.symmetric(
horizontal: UiConstants.space2,
vertical: UiConstants.space1,
),
),
minimumSize: WidgetStateProperty.all(const Size(0, 28)),
maximumSize: WidgetStateProperty.all(const Size(double.infinity, 28)),
textStyle: WidgetStateProperty.all(
const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
),
);
case UiButtonSize.small:
return ButtonStyle(
padding: WidgetStateProperty.all(
const EdgeInsets.symmetric(
horizontal: UiConstants.space3,
vertical: UiConstants.space2,
),
),
minimumSize: WidgetStateProperty.all(const Size(0, 36)),
maximumSize: WidgetStateProperty.all(const Size(double.infinity, 36)),
textStyle: WidgetStateProperty.all(
const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
),
);
case UiButtonSize.medium:
return ButtonStyle(
padding: WidgetStateProperty.all(
const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space3,
),
),
minimumSize: WidgetStateProperty.all(const Size(0, 44)),
maximumSize: WidgetStateProperty.all(const Size(double.infinity, 44)),
);
case UiButtonSize.large:
return ButtonStyle(
padding: WidgetStateProperty.all(
const EdgeInsets.symmetric(
horizontal: UiConstants.space6,
vertical: UiConstants.space4,
),
),
minimumSize: WidgetStateProperty.all(const Size(0, 52)),
maximumSize: WidgetStateProperty.all(const Size(double.infinity, 52)),
textStyle: WidgetStateProperty.all(
const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
),
);
}
}
/// Builds the button content with optional leading and trailing icons.
Widget _buildButtonContent() {
if (child != null) {
@@ -229,6 +292,9 @@ class UiButton extends StatelessWidget {
/// Defines the size of a [UiButton].
enum UiButtonSize {
/// Extra small button (very compact)
extraSmall,
/// Small button (compact)
small,

View File

@@ -87,6 +87,11 @@ export 'src/adapters/clock_in/clock_in_adapter.dart';
export 'src/entities/availability/availability_slot.dart';
export 'src/entities/availability/day_availability.dart';
// Coverage
export 'src/entities/coverage_domain/coverage_shift.dart';
export 'src/entities/coverage_domain/coverage_worker.dart';
export 'src/entities/coverage_domain/coverage_stats.dart';
// Adapters
export 'src/adapters/profile/emergency_contact_adapter.dart';
export 'src/adapters/profile/experience_adapter.dart';

View File

@@ -1,10 +1,59 @@
import 'package:intl/intl.dart';
import '../../entities/shifts/shift.dart';
/// Adapter for Shift related data.
class ShiftAdapter {
// Note: Conversion logic will likely live in RepoImpl or here if we pass raw objects.
// Given we are dealing with generated types that aren't exported by domain,
// we might put the logic in Repo or make this accept dynamic/Map if strictly required.
// For now, placeholders or simple status helpers.
/// Maps application data to a Shift entity.
///
/// This method handles the common mapping logic used across different
/// repositories when converting application data from Data Connect to
/// domain Shift entities.
static Shift fromApplicationData({
required String shiftId,
required String roleId,
required String roleName,
required String businessName,
String? companyLogoUrl,
required double costPerHour,
String? shiftLocation,
required String teamHubName,
DateTime? shiftDate,
DateTime? startTime,
DateTime? endTime,
DateTime? createdAt,
required String status,
String? description,
int? durationDays,
required int count,
int? assigned,
String? eventName,
bool hasApplied = false,
}) {
final String orderName = (eventName ?? '').trim().isNotEmpty
? eventName!
: businessName;
final String title = '$roleName - $orderName';
return Shift(
id: shiftId,
roleId: roleId,
title: title,
clientName: businessName,
logoUrl: companyLogoUrl,
hourlyRate: costPerHour,
location: shiftLocation ?? '',
locationAddress: teamHubName,
date: shiftDate?.toIso8601String() ?? '',
startTime: startTime != null ? DateFormat('HH:mm').format(startTime) : '',
endTime: endTime != null ? DateFormat('HH:mm').format(endTime) : '',
createdDate: createdAt?.toIso8601String() ?? '',
status: status,
description: description,
durationDays: durationDays,
requiredSlots: count,
filledSlots: assigned ?? 0,
hasApplied: hasApplied,
);
}
}

View File

@@ -0,0 +1,57 @@
import 'package:equatable/equatable.dart';
import 'coverage_worker.dart';
/// Domain entity representing a shift in the coverage view.
///
/// This is a feature-specific domain entity that encapsulates shift information
/// including scheduling details and assigned workers.
class CoverageShift extends Equatable {
/// Creates a [CoverageShift].
const CoverageShift({
required this.id,
required this.title,
required this.location,
required this.startTime,
required this.workersNeeded,
required this.date,
required this.workers,
});
/// The unique identifier for the shift.
final String id;
/// The title or role of the shift.
final String title;
/// The location where the shift takes place.
final String location;
/// The start time of the shift (e.g., "16:00").
final String startTime;
/// The number of workers needed for this shift.
final int workersNeeded;
/// The date of the shift.
final DateTime date;
/// The list of workers assigned to this shift.
final List<CoverageWorker> workers;
/// Calculates the coverage percentage for this shift.
int get coveragePercent {
if (workersNeeded == 0) return 0;
return ((workers.length / workersNeeded) * 100).round();
}
@override
List<Object?> get props => <Object?>[
id,
title,
location,
startTime,
workersNeeded,
date,
workers,
];
}

View File

@@ -0,0 +1,45 @@
import 'package:equatable/equatable.dart';
/// Domain entity representing coverage statistics.
///
/// Aggregates coverage metrics for a specific date.
class CoverageStats extends Equatable {
/// Creates a [CoverageStats].
const CoverageStats({
required this.totalNeeded,
required this.totalConfirmed,
required this.checkedIn,
required this.enRoute,
required this.late,
});
/// The total number of workers needed.
final int totalNeeded;
/// The total number of confirmed workers.
final int totalConfirmed;
/// The number of workers who have checked in.
final int checkedIn;
/// The number of workers en route.
final int enRoute;
/// The number of late workers.
final int late;
/// Calculates the overall coverage percentage.
int get coveragePercent {
if (totalNeeded == 0) return 0;
return ((totalConfirmed / totalNeeded) * 100).round();
}
@override
List<Object?> get props => <Object?>[
totalNeeded,
totalConfirmed,
checkedIn,
enRoute,
late,
];
}

View File

@@ -0,0 +1,55 @@
import 'package:equatable/equatable.dart';
/// Worker status enum matching ApplicationStatus from Data Connect.
enum CoverageWorkerStatus {
/// Application is pending approval.
pending,
/// Application has been accepted.
accepted,
/// Application has been rejected.
rejected,
/// Worker has confirmed attendance.
confirmed,
/// Worker has checked in.
checkedIn,
/// Worker has checked out.
checkedOut,
/// Worker is late.
late,
/// Worker did not show up.
noShow,
/// Shift is completed.
completed,
}
/// Domain entity representing a worker in the coverage view.
///
/// This entity tracks worker status including check-in information.
class CoverageWorker extends Equatable {
/// Creates a [CoverageWorker].
const CoverageWorker({
required this.name,
required this.status,
this.checkInTime,
});
/// The name of the worker.
final String name;
/// The status of the worker.
final CoverageWorkerStatus status;
/// The time the worker checked in, if applicable.
final String? checkInTime;
@override
List<Object?> get props => <Object?>[name, status, checkInTime];
}

View File

@@ -200,6 +200,8 @@ class _BillingViewState extends State<BillingView> {
const SpendingBreakdownCard(),
if (state.invoiceHistory.isEmpty) _buildEmptyState(context)
else InvoiceHistorySection(invoices: state.invoiceHistory),
const SizedBox(height: UiConstants.space32),
],
),
);

View File

@@ -1,7 +1,7 @@
import 'package:firebase_data_connect/firebase_data_connect.dart' as fdc;
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart';
import '../../domain/repositories/coverage_repository.dart';
import '../../domain/ui_entities/coverage_entities.dart';
/// Implementation of [CoverageRepository] in the Data layer.
///
@@ -25,18 +25,14 @@ class CoverageRepositoryImpl implements CoverageRepository {
Future<List<CoverageShift>> getShiftsForDate({required DateTime date}) async {
final String? businessId =
dc.ClientSessionStore.instance.session?.business?.id;
print('Coverage: now=${DateTime.now().toIso8601String()}');
if (businessId == null || businessId.isEmpty) {
print('Coverage: missing businessId for date=${date.toIso8601String()}');
return <CoverageShift>[];
}
final DateTime start = DateTime(date.year, date.month, date.day);
final DateTime end =
DateTime(date.year, date.month, date.day, 23, 59, 59, 999);
print(
'Coverage: request businessId=$businessId dayStart=${start.toIso8601String()} dayEnd=${end.toIso8601String()}',
);
final fdc.QueryResult<
dc.ListShiftRolesByBusinessAndDateRangeData,
dc.ListShiftRolesByBusinessAndDateRangeVariables> shiftRolesResult =
@@ -58,9 +54,6 @@ class CoverageRepositoryImpl implements CoverageRepository {
dayEnd: _toTimestamp(end),
)
.execute();
print(
'Coverage: ${date.toIso8601String()} staffsApplications=${applicationsResult.data.applications.length}',
);
return _mapCoverageShifts(
shiftRolesResult.data.shiftRoles,
@@ -84,11 +77,16 @@ class CoverageRepositoryImpl implements CoverageRepository {
final List<CoverageWorker> allWorkers =
shifts.expand((CoverageShift shift) => shift.workers).toList();
final int totalConfirmed = allWorkers.length;
final int checkedIn =
allWorkers.where((CoverageWorker w) => w.isCheckedIn).length;
final int enRoute =
allWorkers.where((CoverageWorker w) => w.isEnRoute).length;
final int late = allWorkers.where((CoverageWorker w) => w.isLate).length;
final int checkedIn = allWorkers
.where((CoverageWorker w) => w.status == CoverageWorkerStatus.checkedIn)
.length;
final int enRoute = allWorkers
.where((CoverageWorker w) =>
w.status == CoverageWorkerStatus.confirmed && w.checkInTime == null)
.length;
final int late = allWorkers
.where((CoverageWorker w) => w.status == CoverageWorkerStatus.late)
.length;
return CoverageStats(
totalNeeded: totalNeeded,
@@ -172,25 +170,32 @@ class CoverageRepositoryImpl implements CoverageRepository {
.toList();
}
String _mapWorkerStatus(
CoverageWorkerStatus _mapWorkerStatus(
dc.EnumValue<dc.ApplicationStatus> status,
) {
if (status is dc.Known<dc.ApplicationStatus>) {
switch (status.value) {
case dc.ApplicationStatus.LATE:
return 'late';
case dc.ApplicationStatus.CHECKED_IN:
case dc.ApplicationStatus.CHECKED_OUT:
case dc.ApplicationStatus.ACCEPTED:
case dc.ApplicationStatus.CONFIRMED:
case dc.ApplicationStatus.PENDING:
return CoverageWorkerStatus.pending;
case dc.ApplicationStatus.ACCEPTED:
return CoverageWorkerStatus.confirmed;
case dc.ApplicationStatus.REJECTED:
return CoverageWorkerStatus.rejected;
case dc.ApplicationStatus.CONFIRMED:
return CoverageWorkerStatus.confirmed;
case dc.ApplicationStatus.CHECKED_IN:
return CoverageWorkerStatus.checkedIn;
case dc.ApplicationStatus.CHECKED_OUT:
return CoverageWorkerStatus.checkedOut;
case dc.ApplicationStatus.LATE:
return CoverageWorkerStatus.late;
case dc.ApplicationStatus.NO_SHOW:
return CoverageWorkerStatus.noShow;
case dc.ApplicationStatus.COMPLETED:
return 'confirmed';
return CoverageWorkerStatus.completed;
}
}
return 'confirmed';
return CoverageWorkerStatus.pending;
}
String? _formatTime(fdc.Timestamp? timestamp) {

View File

@@ -1,4 +1,4 @@
import '../ui_entities/coverage_entities.dart';
import 'package:krow_domain/krow_domain.dart';
/// Repository interface for coverage-related operations.
///

View File

@@ -1,133 +0,0 @@
import 'package:equatable/equatable.dart';
/// Domain entity representing a shift in the coverage view.
///
/// This is a feature-specific domain entity that encapsulates shift information
/// including scheduling details and assigned workers.
class CoverageShift extends Equatable {
/// Creates a [CoverageShift].
const CoverageShift({
required this.id,
required this.title,
required this.location,
required this.startTime,
required this.workersNeeded,
required this.date,
required this.workers,
});
/// The unique identifier for the shift.
final String id;
/// The title or role of the shift.
final String title;
/// The location where the shift takes place.
final String location;
/// The start time of the shift (e.g., "16:00").
final String startTime;
/// The number of workers needed for this shift.
final int workersNeeded;
/// The date of the shift.
final DateTime date;
/// The list of workers assigned to this shift.
final List<CoverageWorker> workers;
/// Calculates the coverage percentage for this shift.
int get coveragePercent {
if (workersNeeded == 0) return 0;
return ((workers.length / workersNeeded) * 100).round();
}
@override
List<Object?> get props => <Object?>[
id,
title,
location,
startTime,
workersNeeded,
date,
workers,
];
}
/// Domain entity representing a worker in the coverage view.
///
/// This entity tracks worker status including check-in information.
class CoverageWorker extends Equatable {
/// Creates a [CoverageWorker].
const CoverageWorker({
required this.name,
required this.status,
this.checkInTime,
});
/// The name of the worker.
final String name;
/// The status of the worker ('confirmed', 'late', etc.).
final String status;
/// The time the worker checked in, if applicable.
final String? checkInTime;
/// Returns true if the worker is checked in.
bool get isCheckedIn => status == 'confirmed' && checkInTime != null;
/// Returns true if the worker is en route.
bool get isEnRoute => status == 'confirmed' && checkInTime == null;
/// Returns true if the worker is late.
bool get isLate => status == 'late';
@override
List<Object?> get props => <Object?>[name, status, checkInTime];
}
/// Domain entity representing coverage statistics.
///
/// Aggregates coverage metrics for a specific date.
class CoverageStats extends Equatable {
/// Creates a [CoverageStats].
const CoverageStats({
required this.totalNeeded,
required this.totalConfirmed,
required this.checkedIn,
required this.enRoute,
required this.late,
});
/// The total number of workers needed.
final int totalNeeded;
/// The total number of confirmed workers.
final int totalConfirmed;
/// The number of workers who have checked in.
final int checkedIn;
/// The number of workers en route.
final int enRoute;
/// The number of late workers.
final int late;
/// Calculates the overall coverage percentage.
int get coveragePercent {
if (totalNeeded == 0) return 0;
return ((totalConfirmed / totalNeeded) * 100).round();
}
@override
List<Object?> get props => <Object?>[
totalNeeded,
totalConfirmed,
checkedIn,
enRoute,
late,
];
}

View File

@@ -1,7 +1,8 @@
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.dart';
import '../arguments/get_coverage_stats_arguments.dart';
import '../repositories/coverage_repository.dart';
import '../ui_entities/coverage_entities.dart';
/// Use case for fetching coverage statistics for a specific date.
///

View File

@@ -1,7 +1,7 @@
import 'package:krow_core/core.dart';
import '../arguments/get_shifts_for_date_arguments.dart';
import '../repositories/coverage_repository.dart';
import '../ui_entities/coverage_entities.dart';
import 'package:krow_domain/krow_domain.dart';
/// Use case for fetching shifts for a specific date.
///

View File

@@ -1,7 +1,7 @@
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../domain/arguments/get_coverage_stats_arguments.dart';
import '../../domain/arguments/get_shifts_for_date_arguments.dart';
import '../../domain/ui_entities/coverage_entities.dart';
import 'package:krow_domain/krow_domain.dart';
import '../../domain/usecases/get_coverage_stats_usecase.dart';
import '../../domain/usecases/get_shifts_for_date_usecase.dart';
import 'coverage_event.dart';

View File

@@ -1,5 +1,5 @@
import 'package:equatable/equatable.dart';
import '../../domain/ui_entities/coverage_entities.dart';
import 'package:krow_domain/krow_domain.dart';
/// Enum representing the status of coverage data loading.
enum CoverageStatus {

View File

@@ -1,6 +1,6 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import '../../domain/ui_entities/coverage_entities.dart';
import 'package:krow_domain/krow_domain.dart';
/// Quick statistics cards showing coverage metrics.
///

View File

@@ -1,7 +1,7 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../domain/ui_entities/coverage_entities.dart';
import 'package:krow_domain/krow_domain.dart';
/// List of shifts with their workers.
///
@@ -194,7 +194,8 @@ class _ShiftHeader extends StatelessWidget {
size: UiConstants.space3,
color: UiColors.iconSecondary,
),
Expanded(child: Text(
Expanded(
child: Text(
location,
style: UiTypography.body3r.textSecondary,
overflow: TextOverflow.ellipsis,
@@ -314,36 +315,92 @@ class _WorkerRow extends StatelessWidget {
Color badgeText;
String badgeLabel;
if (worker.isCheckedIn) {
bg = UiColors.textSuccess.withOpacity(0.1);
border = UiColors.textSuccess;
textBg = UiColors.textSuccess.withOpacity(0.2);
textColor = UiColors.textSuccess;
icon = UiIcons.success;
statusText = '✓ Checked In at ${formatTime(worker.checkInTime)}';
badgeBg = UiColors.textSuccess;
badgeText = UiColors.primaryForeground;
badgeLabel = 'On Site';
} else if (worker.isEnRoute) {
bg = UiColors.textWarning.withOpacity(0.1);
border = UiColors.textWarning;
textBg = UiColors.textWarning.withOpacity(0.2);
textColor = UiColors.textWarning;
icon = UiIcons.clock;
statusText = 'En Route - Expected $shiftStartTime';
badgeBg = UiColors.textWarning;
badgeText = UiColors.primaryForeground;
badgeLabel = 'En Route';
} else {
bg = UiColors.destructive.withOpacity(0.1);
border = UiColors.destructive;
textBg = UiColors.destructive.withOpacity(0.2);
textColor = UiColors.destructive;
icon = UiIcons.warning;
statusText = '⚠ Running Late';
badgeBg = UiColors.destructive;
badgeText = UiColors.destructiveForeground;
badgeLabel = 'Late';
switch (worker.status) {
case CoverageWorkerStatus.checkedIn:
bg = UiColors.textSuccess.withOpacity(0.1);
border = UiColors.textSuccess;
textBg = UiColors.textSuccess.withOpacity(0.2);
textColor = UiColors.textSuccess;
icon = UiIcons.success;
statusText = '✓ Checked In at ${formatTime(worker.checkInTime)}';
badgeBg = UiColors.textSuccess;
badgeText = UiColors.primaryForeground;
badgeLabel = 'On Site';
case CoverageWorkerStatus.confirmed:
if (worker.checkInTime == null) {
bg = UiColors.textWarning.withOpacity(0.1);
border = UiColors.textWarning;
textBg = UiColors.textWarning.withOpacity(0.2);
textColor = UiColors.textWarning;
icon = UiIcons.clock;
statusText = 'En Route - Expected $shiftStartTime';
badgeBg = UiColors.textWarning;
badgeText = UiColors.primaryForeground;
badgeLabel = 'En Route';
} else {
bg = UiColors.muted.withOpacity(0.1);
border = UiColors.border;
textBg = UiColors.muted.withOpacity(0.2);
textColor = UiColors.textSecondary;
icon = UiIcons.success;
statusText = 'Confirmed';
badgeBg = UiColors.muted;
badgeText = UiColors.textPrimary;
badgeLabel = 'Confirmed';
}
case CoverageWorkerStatus.late:
bg = UiColors.destructive.withOpacity(0.1);
border = UiColors.destructive;
textBg = UiColors.destructive.withOpacity(0.2);
textColor = UiColors.destructive;
icon = UiIcons.warning;
statusText = '⚠ Running Late';
badgeBg = UiColors.destructive;
badgeText = UiColors.destructiveForeground;
badgeLabel = 'Late';
case CoverageWorkerStatus.checkedOut:
bg = UiColors.muted.withOpacity(0.1);
border = UiColors.border;
textBg = UiColors.muted.withOpacity(0.2);
textColor = UiColors.textSecondary;
icon = UiIcons.success;
statusText = 'Checked Out';
badgeBg = UiColors.muted;
badgeText = UiColors.textPrimary;
badgeLabel = 'Done';
case CoverageWorkerStatus.noShow:
bg = UiColors.destructive.withOpacity(0.1);
border = UiColors.destructive;
textBg = UiColors.destructive.withOpacity(0.2);
textColor = UiColors.destructive;
icon = UiIcons.warning;
statusText = 'No Show';
badgeBg = UiColors.destructive;
badgeText = UiColors.destructiveForeground;
badgeLabel = 'No Show';
case CoverageWorkerStatus.completed:
bg = UiColors.textSuccess.withOpacity(0.1);
border = UiColors.textSuccess;
textBg = UiColors.textSuccess.withOpacity(0.2);
textColor = UiColors.textSuccess;
icon = UiIcons.success;
statusText = 'Completed';
badgeBg = UiColors.textSuccess;
badgeText = UiColors.primaryForeground;
badgeLabel = 'Completed';
case CoverageWorkerStatus.pending:
case CoverageWorkerStatus.accepted:
case CoverageWorkerStatus.rejected:
bg = UiColors.muted.withOpacity(0.1);
border = UiColors.border;
textBg = UiColors.muted.withOpacity(0.2);
textColor = UiColors.textSecondary;
icon = UiIcons.clock;
statusText = worker.status.name.toUpperCase();
badgeBg = UiColors.muted;
badgeText = UiColors.textPrimary;
badgeLabel = worker.status.name[0].toUpperCase() +
worker.status.name.substring(1);
}
return Container(

View File

@@ -43,14 +43,11 @@ class ReorderWidget extends StatelessWidget {
),
if (subtitle != null) ...<Widget>[
const SizedBox(height: UiConstants.space1),
Text(
subtitle!,
style: UiTypography.body2r.textSecondary,
),
Text(subtitle!, style: UiTypography.body2r.textSecondary),
],
const SizedBox(height: UiConstants.space2),
SizedBox(
height: 140,
height: 164,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: recentOrders.length,
@@ -67,13 +64,7 @@ class ReorderWidget extends StatelessWidget {
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: UiConstants.radiusLg,
border: Border.all(color: UiColors.border),
boxShadow: <BoxShadow>[
BoxShadow(
color: UiColors.black.withValues(alpha: 0.02),
blurRadius: 4,
),
],
border: Border.all(color: UiColors.border, width: 0.6),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -129,10 +120,7 @@ class ReorderWidget extends StatelessWidget {
style: UiTypography.body1b,
),
Text(
i18n.per_hr(
amount: order.hourlyRate.toString(),
) +
' · ${order.hours}h',
'${i18n.per_hr(amount: order.hourlyRate.toString())} · ${order.hours}h',
style: UiTypography.footnote2r.textSecondary,
),
],
@@ -145,49 +133,37 @@ class ReorderWidget extends StatelessWidget {
_Badge(
icon: UiIcons.success,
text: order.type,
color: const Color(0xFF2563EB),
bg: const Color(0xFF2563EB),
textColor: UiColors.white,
color: UiColors.primary,
bg: UiColors.buttonSecondaryStill,
textColor: UiColors.primary,
),
const SizedBox(width: UiConstants.space2),
_Badge(
icon: UiIcons.building,
text: '${order.workers}',
color: const Color(0xFF334155),
bg: const Color(0xFFF1F5F9),
textColor: const Color(0xFF334155),
color: UiColors.textSecondary,
bg: UiColors.buttonSecondaryStill,
textColor: UiColors.textSecondary,
),
],
),
const Spacer(),
SizedBox(
height: 28,
width: double.infinity,
child: ElevatedButton.icon(
onPressed: () => onReorderPressed(<String, dynamic>{
'orderId': order.orderId,
'title': order.title,
'location': order.location,
'hourlyRate': order.hourlyRate,
'hours': order.hours,
'workers': order.workers,
'type': order.type,
}),
style: ElevatedButton.styleFrom(
backgroundColor: UiColors.primary,
foregroundColor: UiColors.white,
padding: EdgeInsets.zero,
shape: RoundedRectangleBorder(
borderRadius: UiConstants.radiusMd,
),
elevation: 0,
),
icon: const Icon(UiIcons.zap, size: 12),
label: Text(
i18n.reorder_button,
style: UiTypography.footnote1m,
),
),
UiButton.secondary(
size: UiButtonSize.small,
text: i18n.reorder_button,
leadingIcon: UiIcons.zap,
iconSize: 12,
fullWidth: true,
onPressed: () => onReorderPressed(<String, dynamic>{
'orderId': order.orderId,
'title': order.title,
'location': order.location,
'hourlyRate': order.hourlyRate,
'hours': order.hours,
'workers': order.workers,
'type': order.type,
}),
),
],
),

View File

@@ -35,7 +35,7 @@ class SettingsProfileHeader extends StatelessWidget {
flexibleSpace: FlexibleSpaceBar(
background: Container(
padding: const EdgeInsets.symmetric(horizontal: UiConstants.space8),
margin: const EdgeInsets.only(top: UiConstants.space16),
margin: const EdgeInsets.only(top: UiConstants.space24),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.start,

View File

@@ -1,4 +1,3 @@
import 'dart:ui';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -10,6 +9,7 @@ import '../blocs/view_orders_cubit.dart';
import '../blocs/view_orders_state.dart';
import 'package:krow_domain/krow_domain.dart';
import '../widgets/view_order_card.dart';
import '../widgets/view_orders_header.dart';
import '../navigation/view_orders_navigator.dart';
/// The main page for viewing client orders.
@@ -22,6 +22,7 @@ class ViewOrdersPage extends StatelessWidget {
/// Creates a [ViewOrdersPage].
const ViewOrdersPage({super.key, this.initialDate});
/// The initial date to display orders for.
final DateTime? initialDate;
@override
@@ -37,7 +38,8 @@ class ViewOrdersPage extends StatelessWidget {
class ViewOrdersView extends StatefulWidget {
/// Creates a [ViewOrdersView].
const ViewOrdersView({super.key, this.initialDate});
/// The initial date to display orders for.
final DateTime? initialDate;
@override
@@ -88,376 +90,84 @@ class _ViewOrdersViewState extends State<ViewOrdersView> {
}
return Scaffold(
body: Stack(
children: <Widget>[
// Background Gradient
Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: <Color>[UiColors.bgSecondary, UiColors.white],
stops: <double>[0.0, 0.3],
),
body: SafeArea(
child: Column(
children: <Widget>[
// Header + Filter + Calendar (Sticky behavior)
ViewOrdersHeader(
state: state,
calendarDays: calendarDays,
),
),
SafeArea(
child: Column(
children: <Widget>[
// Header + Filter + Calendar (Sticky behavior)
_buildHeader(
context: context,
state: state,
calendarDays: calendarDays,
),
// Content List
Expanded(
child: filteredOrders.isEmpty
? _buildEmptyState(context: context, state: state)
: ListView(
padding: const EdgeInsets.fromLTRB(
UiConstants.space5,
UiConstants.space4,
UiConstants.space5,
100,
),
children: <Widget>[
if (filteredOrders.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space3,
),
child: Row(
children: <Widget>[
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: dotColor,
shape: BoxShape.circle,
),
),
const SizedBox(
width: UiConstants.space2,
),
Text(
sectionTitle.toUpperCase(),
style: UiTypography.titleUppercase2m
.copyWith(
color: UiColors.textPrimary,
),
),
const SizedBox(
width: UiConstants.space1,
),
Text(
'(${filteredOrders.length})',
style: UiTypography.footnote1r
.copyWith(
color: UiColors.textSecondary,
),
),
],
),
),
...filteredOrders.map(
(OrderItem order) => Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space3,
),
child: ViewOrderCard(order: order),
),
// Content List
Expanded(
child: filteredOrders.isEmpty
? _buildEmptyState(context: context, state: state)
: ListView(
padding: const EdgeInsets.fromLTRB(
UiConstants.space5,
UiConstants.space4,
UiConstants.space5,
100,
),
children: <Widget>[
if (filteredOrders.isNotEmpty)
Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space3,
),
],
child: Row(
children: <Widget>[
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: dotColor,
shape: BoxShape.circle,
),
),
const SizedBox(
width: UiConstants.space2,
),
Text(
sectionTitle.toUpperCase(),
style: UiTypography.titleUppercase2m
.copyWith(
color: UiColors.textPrimary,
),
),
const SizedBox(
width: UiConstants.space1,
),
Text(
'(${filteredOrders.length})',
style: UiTypography.footnote1r
.copyWith(
color: UiColors.textSecondary,
),
),
],
),
),
...filteredOrders.map(
(OrderItem order) => Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space3,
),
child: ViewOrderCard(order: order),
),
),
),
],
],
),
),
),
],
],
),
),
);
},
);
}
/// Builds the sticky header section.
Widget _buildHeader({
required BuildContext context,
required ViewOrdersState state,
required List<DateTime> calendarDays,
}) {
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
decoration: const BoxDecoration(
color: Color(0xCCFFFFFF), // White with 0.8 alpha
border: Border(
bottom: BorderSide(color: UiColors.separatorSecondary),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
// Top Bar
Padding(
padding: const EdgeInsets.fromLTRB(
UiConstants.space5,
UiConstants.space5,
UiConstants.space5,
UiConstants.space3,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
t.client_view_orders.title,
style: UiTypography.headline3m.copyWith(
color: UiColors.textPrimary,
fontWeight: FontWeight.bold,
),
),
if (state.filteredOrders.isNotEmpty)
UiButton.primary(
text: t.client_view_orders.post_button,
leadingIcon: UiIcons.add,
onPressed: () => Modular.to.navigateToCreateOrder(),
size: UiButtonSize.small,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 48),
maximumSize: const Size(0, 48),
),
),
],
),
),
// Filter Tabs
Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
_buildFilterTab(
context,
label: t.client_view_orders.tabs.up_next,
isSelected: state.filterTab == 'all',
tabId: 'all',
count: state.upNextCount,
),
const SizedBox(width: UiConstants.space6),
_buildFilterTab(
context,
label: t.client_view_orders.tabs.active,
isSelected: state.filterTab == 'active',
tabId: 'active',
count: state.activeCount,
),
const SizedBox(width: UiConstants.space6),
_buildFilterTab(
context,
label: t.client_view_orders.tabs.completed,
isSelected: state.filterTab == 'completed',
tabId: 'completed',
count: state.completedCount,
),
],
),
),
// Calendar Header controls
Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
vertical: UiConstants.space2,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
IconButton(
icon: const Icon(
UiIcons.chevronLeft,
size: 20,
color: UiColors.iconSecondary,
),
onPressed: () => BlocProvider.of<ViewOrdersCubit>(
context,
).updateWeekOffset(-1),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
splashRadius: 20,
),
Text(
DateFormat('MMMM yyyy').format(calendarDays.first),
style: UiTypography.body2m.copyWith(
color: UiColors.textSecondary,
),
),
IconButton(
icon: const Icon(
UiIcons.chevronRight,
size: 20,
color: UiColors.iconSecondary,
),
onPressed: () => BlocProvider.of<ViewOrdersCubit>(
context,
).updateWeekOffset(1),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
splashRadius: 20,
),
],
),
),
// Calendar Grid
SizedBox(
height: 72,
child: ListView.separated(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
),
scrollDirection: Axis.horizontal,
itemCount: 7,
separatorBuilder: (BuildContext context, int index) =>
const SizedBox(width: UiConstants.space2),
itemBuilder: (BuildContext context, int index) {
final DateTime date = calendarDays[index];
final bool isSelected =
state.selectedDate != null &&
date.year == state.selectedDate!.year &&
date.month == state.selectedDate!.month &&
date.day == state.selectedDate!.day;
// Check if this date has any shifts
final String dateStr = DateFormat(
'yyyy-MM-dd',
).format(date);
final bool hasShifts = state.orders.any(
(OrderItem s) => s.date == dateStr,
);
return GestureDetector(
onTap: () => BlocProvider.of<ViewOrdersCubit>(
context,
).selectDate(date),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 48,
decoration: BoxDecoration(
color: isSelected ? UiColors.primary : UiColors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected
? UiColors.primary
: UiColors.separatorPrimary,
),
boxShadow: isSelected
? <BoxShadow>[
BoxShadow(
color: UiColors.primary.withValues(
alpha: 0.25,
),
blurRadius: 12,
offset: const Offset(0, 4),
),
]
: null,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
DateFormat('dd').format(date),
style: UiTypography.title2b.copyWith(
fontSize: 18,
color: isSelected
? UiColors.white
: UiColors.textPrimary,
),
),
Text(
DateFormat('E').format(date),
style: UiTypography.footnote2m.copyWith(
color: isSelected
? UiColors.white.withValues(alpha: 0.8)
: UiColors.textSecondary,
),
),
if (hasShifts) ...<Widget>[
const SizedBox(height: UiConstants.space1),
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: isSelected
? UiColors.white
: UiColors.primary,
shape: BoxShape.circle,
),
),
],
],
),
),
);
},
),
),
const SizedBox(height: UiConstants.space4),
],
),
),
),
);
}
/// Builds a single filter tab.
Widget _buildFilterTab(
BuildContext context, {
required String label,
required bool isSelected,
required String tabId,
int? count,
}) {
String text = label;
if (count != null) {
text = '$label ($count)';
}
return GestureDetector(
onTap: () =>
BlocProvider.of<ViewOrdersCubit>(context).selectFilterTab(tabId),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space2),
child: Text(
text,
style: UiTypography.body2m.copyWith(
color: isSelected ? UiColors.primary : UiColors.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: 2,
width: isSelected ? 40 : 0,
decoration: BoxDecoration(
color: UiColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
if (!isSelected) const SizedBox(height: 2),
],
),
);
}
/// Builds the empty state view.
Widget _buildEmptyState({
required BuildContext context,

View File

@@ -8,6 +8,7 @@ import 'package:intl/intl.dart';
import 'package:krow_data_connect/krow_data_connect.dart' as dc;
import 'package:krow_domain/krow_domain.dart';
import 'package:url_launcher/url_launcher.dart';
import '../blocs/view_orders_cubit.dart';
/// A rich card displaying details of a client order/shift.
@@ -325,15 +326,23 @@ class _ViewOrderCardState extends State<ViewOrderCard> {
children: <Widget>[
Row(
children: <Widget>[
if (order.workersNeeded != 0)
if (coveragePercent != 100)
const Icon(
UiIcons.error,
size: 16,
color: UiColors.textError,
),
if (coveragePercent == 100)
const Icon(
UiIcons.checkCircle,
size: 16,
color: UiColors.textSuccess,
),
const SizedBox(width: 8),
Text(
'${order.workersNeeded} Workers Needed',
coveragePercent == 100
? 'All Workers Confirmed'
: '${order.workersNeeded} Workers Needed',
style: UiTypography.body2m.textPrimary,
),
],

View File

@@ -0,0 +1,68 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../blocs/view_orders_cubit.dart';
/// A single filter tab for the View Orders page.
///
/// Displays a label with an optional count and shows a selection indicator
/// when the tab is active.
class ViewOrdersFilterTab extends StatelessWidget {
/// Creates a [ViewOrdersFilterTab].
const ViewOrdersFilterTab({
required this.label,
required this.isSelected,
required this.tabId,
this.count,
super.key,
});
/// The label text to display.
final String label;
/// Whether this tab is currently selected.
final bool isSelected;
/// The unique identifier for this tab.
final String tabId;
/// Optional count to display next to the label.
final int? count;
@override
Widget build(BuildContext context) {
String text = label;
if (count != null) {
text = '$label ($count)';
}
return GestureDetector(
onTap: () =>
BlocProvider.of<ViewOrdersCubit>(context).selectFilterTab(tabId),
child: Column(
children: <Widget>[
Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space2),
child: Text(
text,
style: UiTypography.body2m.copyWith(
color: isSelected ? UiColors.primary : UiColors.textSecondary,
fontWeight: FontWeight.w600,
),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
height: 2,
width: isSelected ? 40 : 0,
decoration: BoxDecoration(
color: UiColors.primary,
borderRadius: BorderRadius.circular(2),
),
),
if (!isSelected) const SizedBox(height: 2),
],
),
);
}
}

View File

@@ -0,0 +1,268 @@
import 'dart:ui';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:intl/intl.dart';
import 'package:core_localization/core_localization.dart';
import 'package:krow_domain/krow_domain.dart';
import '../blocs/view_orders_cubit.dart';
import '../blocs/view_orders_state.dart';
import '../navigation/view_orders_navigator.dart';
import 'view_orders_filter_tab.dart';
/// The sticky header section for the View Orders page.
///
/// This widget contains:
/// - Top bar with title and post button
/// - Filter tabs (Up Next, Active, Completed)
/// - Calendar navigation controls
/// - Horizontal calendar grid
class ViewOrdersHeader extends StatelessWidget {
/// Creates a [ViewOrdersHeader].
const ViewOrdersHeader({
required this.state,
required this.calendarDays,
super.key,
});
/// The current state of the view orders feature.
final ViewOrdersState state;
/// The list of calendar days to display.
final List<DateTime> calendarDays;
@override
Widget build(BuildContext context) {
return ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
decoration: const BoxDecoration(
color: Color(0xCCFFFFFF), // White with 0.8 alpha
border: Border(
bottom: BorderSide(color: UiColors.separatorSecondary),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
// Top Bar
Padding(
padding: const EdgeInsets.fromLTRB(
UiConstants.space5,
UiConstants.space5,
UiConstants.space5,
UiConstants.space3,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
Text(
t.client_view_orders.title,
style: UiTypography.headline3m.copyWith(
color: UiColors.textPrimary,
fontWeight: FontWeight.bold,
),
),
if (state.filteredOrders.isNotEmpty)
UiButton.primary(
text: t.client_view_orders.post_button,
leadingIcon: UiIcons.add,
onPressed: () => Modular.to.navigateToCreateOrder(),
size: UiButtonSize.small,
style: ElevatedButton.styleFrom(
minimumSize: const Size(0, 48),
maximumSize: const Size(0, 48),
),
),
],
),
),
// Filter Tabs
Padding(
padding: const EdgeInsets.only(bottom: UiConstants.space3),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ViewOrdersFilterTab(
label: t.client_view_orders.tabs.up_next,
isSelected: state.filterTab == 'all',
tabId: 'all',
count: state.upNextCount,
),
const SizedBox(width: UiConstants.space6),
ViewOrdersFilterTab(
label: t.client_view_orders.tabs.active,
isSelected: state.filterTab == 'active',
tabId: 'active',
count: state.activeCount,
),
const SizedBox(width: UiConstants.space6),
ViewOrdersFilterTab(
label: t.client_view_orders.tabs.completed,
isSelected: state.filterTab == 'completed',
tabId: 'completed',
count: state.completedCount,
),
],
),
),
// Calendar Header controls
Padding(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
vertical: UiConstants.space2,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: <Widget>[
IconButton(
icon: const Icon(
UiIcons.chevronLeft,
size: 20,
color: UiColors.iconSecondary,
),
onPressed: () => BlocProvider.of<ViewOrdersCubit>(
context,
).updateWeekOffset(-1),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
splashRadius: 20,
),
Text(
DateFormat('MMMM yyyy').format(calendarDays.first),
style: UiTypography.body2m.copyWith(
color: UiColors.textSecondary,
),
),
IconButton(
icon: const Icon(
UiIcons.chevronRight,
size: 20,
color: UiColors.iconSecondary,
),
onPressed: () => BlocProvider.of<ViewOrdersCubit>(
context,
).updateWeekOffset(1),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
splashRadius: 20,
),
],
),
),
// Calendar Grid
SizedBox(
height: 72,
child: ListView.separated(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space5,
),
scrollDirection: Axis.horizontal,
itemCount: 7,
separatorBuilder: (BuildContext context, int index) =>
const SizedBox(width: UiConstants.space2),
itemBuilder: (BuildContext context, int index) {
final DateTime date = calendarDays[index];
final bool isSelected =
state.selectedDate != null &&
date.year == state.selectedDate!.year &&
date.month == state.selectedDate!.month &&
date.day == state.selectedDate!.day;
// Check if this date has any shifts
final String dateStr = DateFormat(
'yyyy-MM-dd',
).format(date);
final bool hasShifts = state.orders.any(
(OrderItem s) => s.date == dateStr,
);
// Check if date is in the past
final DateTime now = DateTime.now();
final DateTime today = DateTime(now.year, now.month, now.day);
final DateTime checkDate = DateTime(date.year, date.month, date.day);
final bool isPast = checkDate.isBefore(today);
return Opacity(
opacity: isPast && !isSelected ? 0.5 : 1.0,
child: GestureDetector(
onTap: () => BlocProvider.of<ViewOrdersCubit>(
context,
).selectDate(date),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 48,
decoration: BoxDecoration(
color: isSelected ? UiColors.primary : UiColors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isSelected
? UiColors.primary
: UiColors.separatorPrimary,
),
boxShadow: isSelected
? <BoxShadow>[
BoxShadow(
color: UiColors.primary.withValues(
alpha: 0.25,
),
blurRadius: 12,
offset: const Offset(0, 4),
),
]
: null,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
DateFormat('dd').format(date),
style: UiTypography.title2b.copyWith(
fontSize: 18,
color: isSelected
? UiColors.white
: UiColors.textPrimary,
),
),
Text(
DateFormat('E').format(date),
style: UiTypography.footnote2m.copyWith(
color: isSelected
? UiColors.white.withValues(alpha: 0.8)
: UiColors.textSecondary,
),
),
if (hasShifts) ...<Widget>[
const SizedBox(height: UiConstants.space1),
Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: isSelected
? UiColors.white
: UiColors.primary,
shape: BoxShape.circle,
),
),
],
],
),
),
),
);
},
),
),
const SizedBox(height: UiConstants.space4),
],
),
),
),
);
}
}

View File

@@ -25,10 +25,14 @@ dependencies:
path: ../../../domain
krow_core:
path: ../../../core
krow_data_connect:
path: ../../../data_connect
# UI
lucide_icons: ^0.257.0
intl: ^0.20.1
url_launcher: ^6.3.1
firebase_data_connect: ^0.2.2+2
firebase_auth: ^6.1.4
dev_dependencies:
flutter_test:

View File

@@ -1,15 +1,15 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:design_system/design_system.dart';
import 'package:staff_authentication/src/domain/ui_entities/auth_mode.dart';
import 'package:staff_authentication/src/presentation/blocs/auth_bloc.dart';
import 'package:staff_authentication/src/presentation/blocs/auth_event.dart';
import 'package:staff_authentication/src/presentation/blocs/auth_state.dart';
import 'package:staff_authentication/src/presentation/blocs/auth_bloc.dart';
import '../widgets/phone_verification_page/phone_input.dart';
import '../widgets/phone_verification_page/otp_verification.dart';
import 'package:staff_authentication/staff_authentication.dart';
import '../navigation/auth_navigator.dart'; // Import the extension
import '../widgets/phone_verification_page/otp_verification.dart';
import '../widgets/phone_verification_page/phone_input.dart';
/// A combined page for phone number entry and OTP verification.
///

View File

@@ -9,15 +9,29 @@ import 'phone_input/phone_input_form_field.dart';
import 'phone_input/phone_input_header.dart';
/// A widget that displays the phone number entry UI.
class PhoneInput extends StatelessWidget {
class PhoneInput extends StatefulWidget {
/// Creates a [PhoneInput].
const PhoneInput({super.key, required this.state, required this.onSendCode});
/// The current state of the authentication process.
final AuthState state;
/// Callback for when the "Send Code" action is triggered.
final VoidCallback onSendCode;
/// Creates a [PhoneInput].
const PhoneInput({super.key, required this.state, required this.onSendCode});
@override
State<PhoneInput> createState() => _PhoneInputState();
}
class _PhoneInputState extends State<PhoneInput> {
void _handlePhoneChanged(String value) {
if (!mounted) return;
final AuthBloc bloc = context.read<AuthBloc>();
if (!bloc.isClosed) {
bloc.add(AuthPhoneUpdated(value));
}
}
@override
Widget build(BuildContext context) {
@@ -35,19 +49,18 @@ class PhoneInput extends StatelessWidget {
const PhoneInputHeader(),
const SizedBox(height: UiConstants.space8),
PhoneInputFormField(
initialValue: state.phoneNumber,
error: state.errorMessage ?? '',
onChanged: (String value) {
BlocProvider.of<AuthBloc>(
context,
).add(AuthPhoneUpdated(value));
},
initialValue: widget.state.phoneNumber,
error: widget.state.errorMessage ?? '',
onChanged: _handlePhoneChanged,
),
],
),
),
),
PhoneInputActions(isLoading: state.isLoading, onSendCode: onSendCode),
PhoneInputActions(
isLoading: widget.state.isLoading,
onSendCode: widget.onSendCode,
),
],
);
}

View File

@@ -32,28 +32,40 @@ class HomeRepositoryImpl implements HomeRepository {
Future<List<Shift>> _getShiftsForDate(DateTime date) async {
try {
final staffId = _currentStaffId;
// Create start and end timestamps for the target date
final DateTime start = DateTime(date.year, date.month, date.day);
final DateTime end = DateTime(date.year, date.month, date.day, 23, 59, 59, 999);
final response = await ExampleConnector.instance
.getApplicationsByStaffId(staffId: _currentStaffId)
.getApplicationsByStaffId(staffId: staffId)
.dayStart(_toTimestamp(start))
.dayEnd(_toTimestamp(end))
.execute();
final targetYmd = DateFormat('yyyy-MM-dd').format(date);
return response.data.applications
.where((app) {
final shiftDate = app.shift.date?.toDate();
if (shiftDate == null) return false;
final isDateMatch = DateFormat('yyyy-MM-dd').format(shiftDate) == targetYmd;
final isAssigned = app.status is Known && (app.status as Known).value == ApplicationStatus.ACCEPTED;
return isDateMatch && isAssigned;
})
.map((app) => _mapApplicationToShift(app))
.toList();
// Filter for ACCEPTED applications (same logic as shifts_repository_impl)
final apps = response.data.applications.where(
(app) => (app.status is Known && (app.status as Known).value == ApplicationStatus.ACCEPTED) || (app.status is Known && (app.status as Known).value == ApplicationStatus.CONFIRMED)
);
final List<Shift> shifts = [];
for (final app in apps) {
shifts.add(_mapApplicationToShift(app));
}
return shifts;
} catch (e) {
return [];
}
}
Timestamp _toTimestamp(DateTime dateTime) {
final DateTime utc = dateTime.toUtc();
final int seconds = utc.millisecondsSinceEpoch ~/ 1000;
final int nanoseconds = (utc.microsecondsSinceEpoch % 1000000) * 1000;
return Timestamp(nanoseconds, seconds);
}
@override
Future<List<Shift>> getRecommendedShifts() async {
@@ -93,21 +105,26 @@ class HomeRepositoryImpl implements HomeRepository {
final s = app.shift;
final r = app.shiftRole;
return Shift(
id: s.id,
title: r.role.name,
clientName: s.order.business.businessName,
hourlyRate: r.role.costPerHour,
location: s.location ?? 'Unknown',
locationAddress: s.location ?? '',
date: s.date?.toDate().toIso8601String() ?? '',
startTime: DateFormat('HH:mm').format(r.startTime?.toDate() ?? DateTime.now()),
endTime: DateFormat('HH:mm').format(r.endTime?.toDate() ?? DateTime.now()),
createdDate: app.createdAt?.toDate().toIso8601String() ?? '',
tipsAvailable: false, // Not in API
mealProvided: false, // Not in API
managers: [], // Not in this query
description: null,
return ShiftAdapter.fromApplicationData(
shiftId: s.id,
roleId: r.roleId,
roleName: r.role.name,
businessName: s.order.business.businessName,
companyLogoUrl: s.order.business.companyLogoUrl,
costPerHour: r.role.costPerHour,
shiftLocation: s.location,
teamHubName: s.order.teamHub.hubName,
shiftDate: s.date?.toDate(),
startTime: r.startTime?.toDate(),
endTime: r.endTime?.toDate(),
createdAt: app.createdAt?.toDate(),
status: 'confirmed',
description: s.description,
durationDays: s.durationDays,
count: r.count,
assigned: r.assigned,
eventName: s.order.eventName,
hasApplied: true,
);
}

View File

@@ -13,7 +13,6 @@ import 'package:staff_home/src/presentation/widgets/home_page/quick_action_item.
import 'package:staff_home/src/presentation/widgets/home_page/recommended_shift_card.dart';
import 'package:staff_home/src/presentation/widgets/home_page/section_header.dart';
import 'package:staff_home/src/presentation/widgets/shift_card.dart';
import 'package:staff_home/src/presentation/widgets/worker/auto_match_toggle.dart';
/// The home page for the staff worker application.
///

View File

@@ -5,6 +5,7 @@ import 'package:intl/intl.dart';
import 'package:design_system/design_system.dart';
import 'package:krow_domain/krow_domain.dart';
import '../navigation/home_navigator.dart';
class ShiftCard extends StatefulWidget {
final Shift shift;
@@ -73,10 +74,7 @@ class _ShiftCardState extends State<ShiftCard> {
? null
: () {
setState(() => isExpanded = !isExpanded);
Modular.to.pushNamed(
'/shift-details/${widget.shift.id}',
arguments: widget.shift,
);
Modular.to.pushShiftDetails(widget.shift);
},
child: Container(
margin: const EdgeInsets.only(bottom: 12),

View File

@@ -83,13 +83,42 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
return null;
}
/// Helper method to map Data Connect application to domain Shift using ShiftAdapter.
Shift _mapApplicationToShift(
dynamic app,
String status, {
bool hasApplied = true,
}) {
return ShiftAdapter.fromApplicationData(
shiftId: app.shift.id,
roleId: app.shiftRole.roleId,
roleName: app.shiftRole.role.name,
businessName: app.shift.order.business.businessName,
companyLogoUrl: app.shift.order.business.companyLogoUrl,
costPerHour: app.shiftRole.role.costPerHour,
shiftLocation: app.shift.location,
teamHubName: app.shift.order.teamHub.hubName,
shiftDate: _toDateTime(app.shift.date),
startTime: _toDateTime(app.shiftRole.startTime),
endTime: _toDateTime(app.shiftRole.endTime),
createdAt: _toDateTime(app.createdAt),
status: status,
description: app.shift.description,
durationDays: app.shift.durationDays,
count: app.shiftRole.count,
assigned: app.shiftRole.assigned,
eventName: app.shift.order.eventName,
hasApplied: hasApplied,
);
}
@override
Future<List<Shift>> getMyShifts({
required DateTime start,
required DateTime end,
}) async {
return _fetchApplications(
dc.ApplicationStatus.ACCEPTED,
[dc.ApplicationStatus.ACCEPTED, dc.ApplicationStatus.CONFIRMED],
start: start,
end: end,
);
@@ -97,12 +126,12 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
@override
Future<List<Shift>> getPendingAssignments() async {
return _fetchApplications(dc.ApplicationStatus.PENDING);
return _fetchApplications([dc.ApplicationStatus.PENDING]);
}
@override
Future<List<Shift>> getCancelledShifts() async {
return _fetchApplications(dc.ApplicationStatus.REJECTED);
return _fetchApplications([dc.ApplicationStatus.REJECTED]);
}
@override
@@ -118,37 +147,10 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
_shiftToAppIdMap[app.shift.id] = app.id;
_appToRoleIdMap[app.id] = app.shiftRole.id;
final String roleName = app.shiftRole.role.name;
final String orderName =
(app.shift.order.eventName ?? '').trim().isNotEmpty
? app.shift.order.eventName!
: app.shift.order.business.businessName;
final String title = '$roleName - $orderName';
final DateTime? shiftDate = _toDateTime(app.shift.date);
final DateTime? startDt = _toDateTime(app.shiftRole.startTime);
final DateTime? endDt = _toDateTime(app.shiftRole.endTime);
final DateTime? createdDt = _toDateTime(app.createdAt);
shifts.add(
Shift(
id: app.shift.id,
roleId: app.shiftRole.roleId,
title: title,
clientName: app.shift.order.business.businessName,
logoUrl: app.shift.order.business.companyLogoUrl,
hourlyRate: app.shiftRole.role.costPerHour,
location: app.shift.location ?? '',
locationAddress: app.shift.order.teamHub.hubName,
date: shiftDate?.toIso8601String() ?? '',
startTime: startDt != null ? DateFormat('HH:mm').format(startDt) : '',
endTime: endDt != null ? DateFormat('HH:mm').format(endDt) : '',
createdDate: createdDt?.toIso8601String() ?? '',
status: _mapStatus(dc.ApplicationStatus.CHECKED_OUT),
description: app.shift.description,
durationDays: app.shift.durationDays,
requiredSlots: app.shiftRole.count,
filledSlots: app.shiftRole.assigned ?? 0,
hasApplied: true,
_mapApplicationToShift(
app,
_mapStatus(dc.ApplicationStatus.CHECKED_OUT),
),
);
}
@@ -159,7 +161,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
}
Future<List<Shift>> _fetchApplications(
dc.ApplicationStatus status, {
List<dc.ApplicationStatus> statuses, {
DateTime? start,
DateTime? end,
}) async {
@@ -173,8 +175,9 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
}
final response = await query.execute();
final statusNames = statuses.map((s) => s.name).toSet();
final apps = response.data.applications.where(
(app) => app.status.stringValue == status.name,
(app) => statusNames.contains(app.status.stringValue),
);
final List<Shift> shifts = [];
@@ -513,7 +516,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
shiftId: shiftId,
staffId: staffId,
roleId: targetRoleId,
status: dc.ApplicationStatus.ACCEPTED,
status: dc.ApplicationStatus.CONFIRMED,
origin: dc.ApplicationOrigin.STAFF,
)
// TODO: this should be PENDING so a vendor can accept it.
@@ -547,7 +550,7 @@ class ShiftsRepositoryImpl implements ShiftsRepositoryInterface {
@override
Future<void> acceptShift(String shiftId) async {
await _updateApplicationStatus(shiftId, dc.ApplicationStatus.ACCEPTED);
await _updateApplicationStatus(shiftId, dc.ApplicationStatus.CONFIRMED);
}
@override

View File

@@ -0,0 +1,908 @@
# 🧪 KROW Workforce Platform - QA Testing Checklist
**Version:** 1.0
**Date:** February 1, 2026
**Coverage:** Client App + Staff App
**Purpose:** Manual QA and Regression Testing
---
## 📋 TABLE OF CONTENTS
1. [Feature-Level QA Checklist](#1⃣-feature-level-qa-checklist)
- [Client App Features](#client-app-features)
- [Staff App Features](#staff-app-features)
2. [Cross-Application Test Scenarios](#2⃣-cross-application-test-scenarios)
3. [Shared Infrastructure Validation](#3⃣-shared-infrastructure-validation)
4. [Regression & Release Checklist](#4⃣-regression--release-checklist)
---
## 1⃣ FEATURE-LEVEL QA CHECKLIST
### CLIENT APP FEATURES
---
#### 📱 CLIENT-001: Authentication
**Applications:** Client
**Entry Points:**
- Launch app → Get Started → Sign In
- Launch app → Get Started → Sign Up
**Happy Path Test Cases:**
- [ ] Sign in with valid email and password displays home dashboard
- [ ] Sign up with business details creates account and navigates to home
**Validation & Error States:**
- [ ] Invalid email format shows validation error
- [ ] Incorrect password shows authentication error
- [ ] Weak password in sign-up shows strength requirements
- [ ] Duplicate email in sign-up shows "already registered" error
- [ ] Empty fields show required field errors
**Loading & Empty States:**
- [ ] Loading spinner displays during authentication
- [ ] OAuth redirect shows appropriate loading state
---
#### 📱 CLIENT-002: Home Dashboard
**Applications:** Client
**Entry Points:**
- Home tab (bottom navigation)
**Happy Path Test Cases:**
- [ ] Dashboard displays current day coverage widget
- [ ] Spending analytics widget shows correct totals
- [ ] Recent reorders display completed shift roles
- [ ] Quick action buttons navigate to correct features
- [ ] Drag-and-drop widget reordering works correctly
**Validation & Error States:**
- [ ] Empty state shows "No data available" when no orders exist
**Loading & Empty States:**
- [ ] Empty coverage shows "No shifts today"
- [ ] Empty reorders shows "No recent orders"
---
#### 📱 CLIENT-003: Create Order
**Applications:** Client
**Entry Points:**
- Home → Create Order button
- Orders tab → + FAB button
- Order type → One-Time
**Happy Path Test Cases:**
- [ ] Order type selection displays.
- [ ] Hub selection shows list of business hubs
- [ ] Role selection displays vendor roles
- [ ] Position quantity can be incremented/decremented (min 1)
- [ ] Date picker displays correct calendar
- [ ] Time pickers show valid time ranges
- [ ] Break duration affects total hours calculation
- [ ] Cost preview calculates correctly (rate × positions × hours)
- [ ] Order submission creates order, shift, and shift roles
- [ ] Success confirmation displays after submission
- [ ] New order appears in View Orders list
**Validation & Error States:**
- [ ] Empty hub field shows validation error
- [ ] Empty role field shows validation error
- [ ] Zero positions shows validation error
- [ ] Invalid date (past) shows validation error
- [ ] Start time after end time shows validation error
- [ ] Missing required fields prevent submission
- [ ] Backend validation errors display appropriately
**Loading & Empty States:**
- [ ] Hub list shows "No hubs" if none exist
- [ ] Role list shows "No roles" if none configured
- [ ] Loading spinner displays during submission
- [ ] Submission progress indicator updates
---
#### 📱 CLIENT-004: View Orders
**Applications:** Client
**Entry Points:**
- Orders tab (bottom navigation)
**Happy Path Test Cases:**
- [ ] Orders list displays orders for selected date
- [ ] Calendar date selection updates order list
- [ ] Each order card shows hub name and address
- [ ] Each order card shows shift time range
- [ ] Each order card shows role positions (filled/total)
- [ ] Each order card shows hourly rate and total cost
- [ ] Accepted applications section displays confirmed staff
- [ ] Staff names and photos display correctly
- [ ] Order list scrolls smoothly with many orders
**Validation & Error States:**
- [ ] Invalid date selection shows error
- [ ] Missing staff data shows placeholder
**Loading & Empty States:**
- [ ] Empty date shows "No orders for this date"
- [ ] Empty accepted applications shows "No confirmed staff"
---
#### 📱 CLIENT-005: Coverage Monitoring
**Applications:** Client
**Entry Points:**
- Coverage tab (bottom navigation)
**Happy Path Test Cases:**
- [ ] Coverage overview displays current date
- [ ] Coverage stats show needed/confirmed/checked-in counts
- [ ] Shift cards display hub name and time range
- [ ] Worker cards show staff name and photo
- [ ] Check-in status indicators update correctly (late, en-route, checked-in)
- [ ] Late workers display with warning indicator
- [ ] Coverage progress bar updates correctly
**Validation & Error States:**
- [ ] Missing worker photo shows default avatar
**Loading & Empty States:**
- [ ] Empty coverage shows "No shifts today"
- [ ] No workers show "No staff assigned"
**State Persistence:**
- [ ] Coverage data refreshes automatically every X minutes
- [ ] Manual refresh via pull-to-refresh gesture
**Backend Dependency Validation:**
- [ ] `listShiftRolesByBusinessAndDateRange` returns shift requirements
- [ ] `listStaffsApplicationsByBusinessForDay` returns staff status
- [ ] Attendance status correctly mapped from backend
---
#### 📱 CLIENT-006: Billing & Invoices
**Applications:** Client
**Entry Points:**
- Billing tab (bottom navigation)
**Happy Path Test Cases:**
- [ ] Current bill amount displays correctly
- [ ] Pending invoices list shows open invoices
- [ ] Invoice history shows paid invoices
- [ ] Savings amount displays correctly
- [ ] Spending breakdown shows costs by role
- [ ] Period filter (weekly/monthly) updates data
- [ ] Invoice detail view shows line items
- [ ] Invoice PDF download works (if implemented)
**Validation & Error States:**
- [ ] Zero billing shows $0.00 (not error)
- [ ] Negative savings shows correctly
- [ ] Missing invoice data shows placeholder
**Loading & Empty States:**
- [ ] Empty pending invoices shows "No pending invoices"
- [ ] Empty history shows "No invoice history"
- [ ] Empty spending breakdown shows "No spending data"
---
#### 📱 CLIENT-007: Hub Management
**Applications:** Client
**Entry Points:**
- Settings → Hubs
- Create Order → Add Hub button
**Happy Path Test Cases:**
- [ ] Hubs list displays all business hubs
- [ ] Hub cards show name and full address
- [ ] Add hub button opens creation form
- [ ] Google Places autocomplete suggests addresses
- [ ] Address selection auto-fills all address fields
- [ ] Hub name can be customized
- [ ] Hub creation adds to list immediately
- [ ] Hub deletion removes from list (with confirmation)
- [ ] Team entity auto-created for business if missing
**Validation & Error States:**
- [ ] Empty hub name shows validation error
- [ ] Empty address shows validation error
- [ ] Invalid address format shows error
- [ ] Duplicate hub name shows warning
- [ ] Hub with active orders prevents deletion (validation error)
**Loading & Empty States:**
- [ ] Empty hubs list shows "No hubs configured"
- [ ] Hub creation shows loading spinner
---
#### 📱 CLIENT-008: Settings
**Applications:** Client
**Entry Points:**
- Settings (navigation menu)
**Happy Path Test Cases:**
- [ ] User profile displays name and email
- [ ] Business name displays correctly
- [ ] Hubs link navigates to hub management
- [ ] Sign out logs out user and returns to auth screen
**Validation & Error States:**
- [ ] Missing profile photo shows default avatar
- [ ] Sign out error shows retry option
**Loading & Empty States:**
- [ ] Profile data loads on page mount
---
#### 📱 CLIENT-009: Client Main Navigation
**Applications:** Client
**Entry Points:**
- Main app shell after authentication
**Happy Path Test Cases:**
- [ ] Bottom navigation displays 5 tabs (Home, Coverage, Billing, Orders, Reports)
- [ ] Tab selection updates active indicator
- [ ] Tab selection navigates to correct feature
- [ ] Deep links navigate to correct tab
- [ ] Back button navigates correctly within nested routes
- [ ] Tab state persists after device rotation
**Validation & Error States:**
- [ ] Invalid route shows 404 or redirects to home
- [ ] Reports tab shows placeholder (not yet implemented)
**Loading & Empty States:**
- [ ] Navigation bar displays immediately
- [ ] Initial tab loads first
---
### STAFF APP FEATURES
---
#### 📱 STAFF-001: Authentication
**Applications:** Staff
**Entry Points:**
- Launch app → Get Started → Phone Verification
**Happy Path Test Cases:**
- [ ] Phone number entry accepts valid formats
- [ ] OTP sent confirmation displays
- [ ] OTP verification succeeds with valid code
- [ ] Profile setup wizard displays for new users
- [ ] Authenticated users bypass auth and show home
**Validation & Error States:**
- [ ] Invalid phone format shows validation error
- [ ] Incorrect OTP shows verification error
- [ ] Expired OTP shows re-send option
- [ ] Empty fields show required field errors
- [ ] Network error displays retry option
**Loading & Empty States:**
- [ ] Loading spinner displays during phone verification
- [ ] OTP input shows countdown timer
- [ ] Profile setup shows progress indicator
---
#### 📱 STAFF-002: Home Dashboard
**Applications:** Staff
**Entry Points:**
- Home tab (bottom navigation)
**Happy Path Test Cases:**
- [ ] Today's shifts display with time and location
- [ ] Tomorrow's shifts display correctly
- [ ] Recommended shifts show available opportunities
- [ ] Shift cards show role, location, and pay rate
- [ ] Quick actions navigate to correct features
- [ ] Dashboard refreshes on pull-to-refresh
**Validation & Error States:**
- [ ] Missing shift data shows placeholder
**Loading & Empty States:**
- [ ] Empty today's shifts shows "No shifts today"
- [ ] Empty recommended shows "No available shifts"
---
#### 📱 STAFF-003: Profile
**Applications:** Staff
**Entry Points:**
- Profile tab (bottom navigation)
**Happy Path Test Cases:**
- [ ] Profile displays name, email, phone, and photo
- [ ] Statistics show total shifts, ratings, reliability score
- [ ] Profile sections list displays all sections
- [ ] Section navigation works correctly
- [ ] Sign out logs out user and returns to auth screen
**Validation & Error States:**
- [ ] Missing profile photo shows default avatar
- [ ] Missing statistics show 0 or default values
- [ ] Sign out error shows retry option
**Loading & Empty States:**
- [ ] Profile data loads on page mount
---
#### 📱 STAFF-004: Shifts Management
**Applications:** Staff
**Entry Points:**
- Shifts tab (bottom navigation)
- Tab navigation: My Shifts / Available / Pending / Cancelled / History
**Happy Path Test Cases:**
- [ ] My Shifts tab displays assigned shifts
- [ ] Available Shifts tab shows open positions
- [ ] Pending tab shows applications awaiting approval
- [ ] Cancelled tab shows cancelled shifts
- [ ] History tab shows past shifts
- [ ] Shift detail view displays full information
- [ ] Accept shift updates status to confirmed
- [ ] Decline shift updates status to declined
- [ ] Apply for shift creates application
- [ ] Shift cards show time, location, role, and pay
**Validation & Error States:**
- [ ] Empty tabs show appropriate empty state messages
- [ ] Already applied shift prevents duplicate application
- [ ] Past shifts cannot be applied to
- [ ] Cancelled shifts show cancellation reason
---
#### 📱 STAFF-005: Availability Management
**Applications:** Staff
**Entry Points:**
- Worker Main → Availability
- Profile → Availability section
**Happy Path Test Cases:**
- [ ] Weekly grid displays Monday-Sunday
- [ ] Time slots (Morning/Afternoon/Evening) toggle correctly
- [ ] Quick-set buttons work (Weekdays/Weekends/All Week)
- [ ] Individual day/slot updates save correctly
- [ ] Green checkmarks indicate availability
- [ ] Gray states indicate unavailability
- [ ] Changes save automatically
**Validation & Error States:**
- [ ] Save failure shows error message
**Loading & Empty States:**
- [ ] Loading spinner displays while fetching availability
- [ ] Default state shows all unavailable
---
#### 📱 STAFF-006: Clock In/Out
**Applications:** Staff
**Entry Points:**
- Clock In tab (bottom navigation)
**Happy Path Test Cases:**
- [ ] Today's shift displays with clock in button
- [ ] Clock in button creates attendance record
- [ ] Clock in time displays correctly
- [ ] Clock out button appears after clocking in
- [ ] Clock out creates end time record
- [ ] Total hours calculated correctly
- [ ] Attendance status updates immediately
**Validation & Error States:**
- [ ] No shift today shows "No shifts to clock in"
- [ ] Already clocked in prevents duplicate clock in
- [ ] Clock in outside shift time shows warning
**Loading & Empty States:**
- [ ] Loading spinner displays while fetching shift
- [ ] Empty state shows "No shifts scheduled"
---
#### 📱 STAFF-007: Payments
**Applications:** Staff
**Entry Points:**
- Payments tab (bottom navigation)
**Happy Path Test Cases:**
- [ ] Payment summary displays total earnings
- [ ] Payment history lists all transactions
- [ ] Payment cards show amount, date, and status
- [ ] Payment detail view shows breakdown
- [ ] Filter by date range works correctly
**Validation & Error States:**
- [ ] Zero earnings show $0.00 (not error)
- [ ] Missing payment data shows placeholder
**Loading & Empty States:**
- [ ] Empty history shows "No payment history"
---
#### 📱 STAFF-008: Personal Info (Onboarding)
**Applications:** Staff
**Entry Points:**
- Profile → Personal Info
- Onboarding wizard
**Happy Path Test Cases:**
- [ ] Form displays current profile data
- [ ] Name field allows text input
- [ ] Email field validates email format
- [ ] Phone field validates phone format
- [ ] Photo upload works correctly
- [ ] Preferred locations multi-select works
- [ ] Save button updates profile
**Validation & Error States:**
- [ ] Empty required fields show validation errors
- [ ] Invalid email format shows error
- [ ] Invalid phone format shows error
- [ ] Photo upload failure shows error
**Loading & Empty States:**
- [ ] Form loads with skeleton placeholders
- [ ] Photo upload shows progress indicator
- [ ] Save button shows loading spinner
---
#### 📱 STAFF-009: Emergency Contact (Onboarding)
**Applications:** Staff
**Entry Points:**
- Profile → Emergency Contact
- Onboarding wizard
**Happy Path Test Cases:**
- [ ] Contact list displays all contacts
- [ ] Add contact button opens form
- [ ] Contact form validates name and phone
- [ ] Relationship dropdown shows options (Family/Spouse/Friend/Other)
- [ ] Remove contact deletes from list
- [ ] Save updates all contacts
- [ ] Multiple contacts supported
**Validation & Error States:**
- [ ] Empty name shows validation error
- [ ] Invalid phone format shows error
- [ ] At least one contact required (if applicable)
**Loading & Empty States:**
- [ ] Loading spinner displays while fetching contacts
- [ ] Empty state shows "No emergency contacts"
- [ ] Save button shows loading spinner
---
#### 📱 STAFF-010: Experience & Skills (Onboarding)
**Applications:** Staff
**Entry Points:**
- Profile → Experience
- Onboarding wizard
**Happy Path Test Cases:**
- [ ] Industries multi-select displays options
- [ ] Skills multi-select displays options
- [ ] Selected items show checkmarks
- [ ] Deselection removes items
- [ ] Save updates profile
**Validation & Error States:**
- [ ] At least one industry required (if applicable)
- [ ] At least one skill required (if applicable)
**Loading & Empty States:**
- [ ] Loading spinner displays while fetching data
- [ ] Save button shows loading spinner
---
#### 📱 STAFF-012: Bank Account (Finances)
**Applications:** Staff
**Entry Points:**
- Profile → Bank Account
**Happy Path Test Cases:**
- [ ] Account list displays all accounts
- [ ] Add account button opens form
- [ ] Form validates routing and account numbers
- [ ] Account type dropdown shows options (Checking/Savings)
- [ ] First account auto-sets as primary
- [ ] Save adds account to list
- [ ] Primary account indicator displays
**Validation & Error States:**
- [ ] Empty routing number shows validation error
- [ ] Invalid routing number format shows error
- [ ] Empty account number shows validation error
- [ ] Invalid account number format shows error
- [ ] Duplicate account shows warning
**Loading & Empty States:**
- [ ] Loading spinner displays while fetching accounts
- [ ] Empty state shows "No bank accounts"
- [ ] Save button shows loading spinner
**State Persistence:**
- [ ] Accounts persist after save
- [ ] Account list refreshes after addition
**Backend Dependency Validation:**
- [ ] `getAccountsByOwnerId` fetches staff accounts
- [ ] `createAccount` creates new account
- [ ] First account auto-flagged as primary
---
#### 📱 STAFF-013: Time Card History (Finances)
**Applications:** Staff
**Entry Points:**
- Profile → Time Card
**Happy Path Test Cases:**
- [ ] Time card list displays all records
- [ ] Each card shows shift details (date, time, location)
- [ ] Each card shows clock in/out times
- [ ] Each card shows total hours worked
- [ ] Scrolling loads more records (pagination)
**Validation & Error States:**
- [ ] Missing attendance data shows "Not recorded"
**Loading & Empty States:**
- [ ] Empty state shows "No time card history"
---
#### 📱 STAFF-014: Tax Forms (Compliance)
**Applications:** Staff
**Entry Points:**
- Profile → Tax Forms
**Happy Path Test Cases:**
- [ ] Forms list displays required forms (I-9, W-4)
- [ ] Form status shows completed/incomplete
- [ ] I-9 form opens editor
- [ ] I-9 form validates all fields
- [ ] W-4 form opens editor
- [ ] W-4 form validates all fields
- [ ] Form submission updates status to completed
- [ ] Completed forms show edit option
**Validation & Error States:**
- [ ] Empty required fields show validation errors
- [ ] Invalid SSN format shows error
- [ ] Invalid date format shows error
- [ ] Signature required validation
**Loading & Empty States:**
- [ ] Loading spinner displays while fetching forms
- [ ] Form editor loads with skeleton placeholders
- [ ] Save button shows loading spinner
---
#### 📱 STAFF-017: Staff Main Navigation
**Applications:** Staff
**Entry Points:**
- Main app shell after authentication
**Happy Path Test Cases:**
- [ ] Bottom navigation displays 5 tabs (Shifts, Payments, Home, Clock In, Profile)
- [ ] Tab selection updates active indicator
- [ ] Tab selection navigates to correct feature
- [ ] Deep links navigate to correct tab and nested route
- [ ] Back button navigates correctly within nested routes
- [ ] Tab state persists after device rotation
- [ ] Nested routes (onboarding, emergency-contact, etc.) accessible
**Validation & Error States:**
- [ ] Invalid route shows 404 or redirects to home
- [ ] Navigation errors log appropriately
**Loading & Empty States:**
- [ ] Navigation bar displays immediately
- [ ] Initial tab loads first
**State Persistence:**
- [ ] Active tab persists after app background → foreground
- [ ] Tab state resets to home on app restart
**Backend Dependency Validation:**
- [ ] No direct backend calls (navigation only)
---
## 2⃣ CROSS-APPLICATION TEST SCENARIOS
### Scenario 1: Order Creation → Staff Application Flow
**Preconditions:**
- Client user authenticated
- Staff user authenticated
- At least one hub configured
**Steps:**
1. **CLIENT APP:**
- [ ] Create one-time order with specific hub, role, date, and time
- [ ] Verify order appears in View Orders list
- [ ] Verify shift shows as unfilled (0/X positions)
2. **STAFF APP:**
- [ ] Open Shifts tab → Available tab
- [ ] Verify new shift appears in available list
- [ ] Verify shift details match order (hub, role, time, pay)
- [ ] Apply for shift position
3. **CLIENT APP:**
- [ ] Refresh View Orders
- [ ] Verify shift shows pending application (0/X filled, pending)
4. **STAFF APP:**
- [ ] Verify application appears in Pending tab
- [ ] Verify shift removed from Available tab
**Expected Results:**
- ✅ Order created in Client appears in Staff Available Shifts
- ✅ Application in Staff shows pending in both apps
- ✅ Shift counts update correctly in real-time
---
### Scenario 2: Shift Acceptance → Coverage Tracking
**Preconditions:**
- Scenario 1 completed (pending application exists)
**Steps:**
1. **STAFF APP:**
- [ ] Go to Shifts → Pending tab
- [ ] Accept pending shift assignment
2. **CLIENT APP:**
- [ ] Refresh View Orders
- [ ] Verify shift shows as filled (1/X positions)
- [ ] Verify staff name and photo appear in accepted applications
- [ ] Navigate to Coverage tab
- [ ] Verify shift appears with assigned staff
3. **STAFF APP:**
- [ ] Verify shift moved from Pending to My Shifts tab
- [ ] Verify shift appears on Home dashboard
**Expected Results:**
- ✅ Accepted shift reflects in Client orders immediately
- ✅ Staff appears in Coverage monitoring
- ✅ Shift moves to My Shifts in Staff app
---
### Scenario 3: Clock In → Real-Time Coverage Update
**Preconditions:**
- Scenario 2 completed (staff has accepted shift)
- Current date/time is during shift window
**Steps:**
1. **STAFF APP:**
- [ ] Navigate to Clock In tab
- [ ] Verify today's shift displays
- [ ] Click Clock In button
- [ ] Verify clock in time recorded
2. **CLIENT APP:**
- [ ] Navigate to Coverage tab
- [ ] Verify staff status changed to "Checked In"
- [ ] Verify check-in time displays
- [ ] Verify coverage stats updated (checked-in count incremented)
3. **STAFF APP:**
- [ ] Wait until shift end time
- [ ] Click Clock Out button
- [ ] Verify clock out time recorded
4. **CLIENT APP:**
- [ ] Refresh Coverage tab
- [ ] Verify staff status changed to "Completed"
5. **STAFF APP:**
- [ ] Navigate to Time Card
- [ ] Verify attendance record appears with correct times and hours
**Expected Results:**
- ✅ Clock in updates Coverage status in Client
- ✅ Clock out completes attendance record
- ✅ Time card displays correct hours in Staff app
- ✅ Coverage monitoring reflects real-time status
---
### Scenario 4: Hub Creation → Order Placement
**Preconditions:**
- Client user authenticated
- No existing hubs
**Steps:**
1. **CLIENT APP:**
- [ ] Navigate to Settings → Hubs
- [ ] Verify empty state "No hubs configured"
- [ ] Click Add Hub button
- [ ] Enter hub name and use Google Places autocomplete
- [ ] Select address from suggestions
- [ ] Verify address fields auto-filled
- [ ] Save hub
2. **CLIENT APP:**
- [ ] Navigate to Create Order
- [ ] Verify new hub appears in hub selection list
- [ ] Select new hub and complete order creation
3. **STAFF APP:**
- [ ] Navigate to Shifts → Available
- [ ] Verify shift shows correct hub name and address
**Expected Results:**
- ✅ Hub created in Settings appears in order creation
- ✅ Hub address propagates to shift details in Staff app
---
### Scenario 5: Shift Cancellation → Staff Notification
**Preconditions:**
- Staff has accepted shift assignment
**Steps:**
1. **CLIENT APP:**
- [ ] Navigate to View Orders
- [ ] Select order with assigned staff
- [ ] Cancel shift (if feature exists) or delete order
2. **STAFF APP:**
- [ ] Refresh Shifts tab
- [ ] Verify shift moved to Cancelled tab
- [ ] Verify shift removed from My Shifts
- [ ] Verify cancellation reason displays
3. **STAFF APP:**
- [ ] Verify shift removed from Home dashboard
**Expected Results:**
- ✅ Cancelled shift moves to Cancelled tab
- ✅ Shift removed from active assignments
- ⚠️ **Requires clarification:** Cancellation feature may not be fully implemented
---
### Scenario 6: Authentication State Sharing
**Preconditions:**
- Neither app authenticated
**Steps:**
1. **CLIENT APP:**
- [ ] Sign in with email/password
- [ ] Verify Firebase Auth token generated
2. **STAFF APP:**
- [ ] Launch app
- [ ] Verify Staff app requires separate authentication
- [ ] Verify Client session does not carry over
3. **CLIENT APP:**
- [ ] Sign out
4. **STAFF APP:**
- [ ] Verify Staff app session persists (independent)
**Expected Results:**
- ✅ Client and Staff apps maintain independent auth sessions
- ✅ Signing out of one app does not affect the other
---
### Scenario 7: Data Created in Client → Visible in Staff
**Preconditions:**
- Client creates multiple orders
**Steps:**
1. **CLIENT APP:**
- [ ] Create 5 orders on different dates
- [ ] Create 3 orders on same date with different hubs
2. **STAFF APP:**
- [ ] Navigate to Shifts → Available
- [ ] Verify all 8 shifts appear
- [ ] Verify date grouping correct
- [ ] Verify hub addresses correct
- [ ] Apply for 2 shifts
3. **CLIENT APP:**
- [ ] Navigate to View Orders
- [ ] Verify 2 shifts show pending applications
- [ ] Navigate to Coverage
- [ ] Verify 0 checked-in (pending acceptance)
**Expected Results:**
- ✅ All orders visible in both apps
- ✅ Application states sync correctly
- ✅ Data consistency maintained across apps
---
### Scenario 8: Role-Based Access Differences
**Preconditions:**
- Client user authenticated
- Staff user authenticated
**Steps:**
1. **CLIENT APP:**
- [ ] Navigate to Billing
- [ ] Verify billing data displays (Client-only feature)
- [ ] Navigate to Create Order
- [ ] Verify order creation available (Client-only feature)
2. **STAFF APP:**
- [ ] Verify no Billing tab exists
- [ ] Verify no Create Order feature
- [ ] Navigate to Availability
- [ ] Verify availability editing available (Staff-only feature)
3. **CLIENT APP:**
- [ ] Verify no Availability feature exists
- [ ] Verify no Clock In feature exists
**Expected Results:**
- ✅ Client app has business management features (orders, billing, hubs)
- ✅ Staff app has worker features (availability, clock in, payments)
- ✅ No feature overlap or unauthorized access
---

View File

@@ -146,6 +146,17 @@
.markdown-content th { background-color: #f9fafb; font-weight: 600; }
.markdown-content img { max-width: 100%; height: auto; border-radius: 0.5em; margin: 1em 0; }
.markdown-content hr { border: none; border-top: 2px solid #e5e7eb; margin: 2em 0; }
/* Mermaid diagram styling */
.mermaid-diagram-wrapper {
display: flex;
justify-content: center;
align-items: center;
}
.mermaid-diagram-wrapper svg {
max-width: 100%;
height: auto;
}
/* Loading Overlay */
#auth-loading {
@@ -821,6 +832,28 @@
const markdownText = await response.text();
const htmlContent = marked.parse(markdownText);
documentContainer.innerHTML = htmlContent;
// Render Mermaid diagrams embedded in the markdown
const mermaidBlocks = documentContainer.querySelectorAll('code.language-mermaid');
for (let i = 0; i < mermaidBlocks.length; i++) {
const block = mermaidBlocks[i];
const mermaidCode = block.textContent;
const pre = block.parentElement;
try {
const { svg } = await mermaid.render(`mermaid-doc-${Date.now()}-${i}`, mermaidCode);
const wrapper = document.createElement('div');
wrapper.className = 'mermaid-diagram-wrapper bg-white p-4 rounded-lg border border-gray-200 my-4 overflow-x-auto';
wrapper.innerHTML = svg;
pre.replaceWith(wrapper);
} catch (err) {
console.error('Mermaid rendering error:', err);
const errorDiv = document.createElement('div');
errorDiv.className = 'bg-red-50 border border-red-200 rounded-lg p-4 my-4';
errorDiv.innerHTML = `<p class="text-red-700 text-sm"><strong>Mermaid Error:</strong> ${err.message}</p>`;
pre.replaceWith(errorDiv);
}
}
} catch (error) {
console.error('Error loading document:', error);
documentContainer.innerHTML = `