Merge branch '312-feature-integrate-google-maps-places-autocomplete-for-hub-address-validation' into fix_staff_app_bugs
This commit is contained in:
10
BLOCKERS.md
10
BLOCKERS.md
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
36
apps/mobile/apps/client/ios/Runner/GoogleService-Info.plist
Normal file
36
apps/mobile/apps/client/ios/Runner/GoogleService-Info.plist
Normal 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>
|
||||
74
apps/mobile/apps/client/lib/firebase_options.dart
Normal file
74
apps/mobile/apps/client/lib/firebase_options.dart
Normal 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',
|
||||
);
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
36
apps/mobile/apps/staff/ios/Runner/GoogleService-Info.plist
Normal file
36
apps/mobile/apps/staff/ios/Runner/GoogleService-Info.plist
Normal 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>
|
||||
74
apps/mobile/apps/staff/lib/firebase_options.dart
Normal file
74
apps/mobile/apps/staff/lib/firebase_options.dart
Normal 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',
|
||||
);
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -11,3 +11,5 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
design_system:
|
||||
path: ../design_system
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import '../ui_entities/coverage_entities.dart';
|
||||
import 'package:krow_domain/krow_domain.dart';
|
||||
|
||||
/// Repository interface for coverage-related operations.
|
||||
///
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
908
docs/QA_TESTING_CHECKLIST.md
Normal file
908
docs/QA_TESTING_CHECKLIST.md
Normal 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
|
||||
|
||||
---
|
||||
@@ -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 = `
|
||||
|
||||
Reference in New Issue
Block a user