diff --git a/BLOCKERS.md b/BLOCKERS.md index 8c5ceb33..af8df57d 100644 --- a/BLOCKERS.md +++ b/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. \ No newline at end of file + - 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. diff --git a/apps/mobile/apps/client/android/app/google-services.json b/apps/mobile/apps/client/android/app/google-services.json index 7533ddc2..20ad2e48 100644 --- a/apps/mobile/apps/client/android/app/google-services.json +++ b/apps/mobile/apps/client/android/app/google-services.json @@ -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" } } ] diff --git a/apps/mobile/apps/client/ios/Runner.xcodeproj/project.pbxproj b/apps/mobile/apps/client/ios/Runner.xcodeproj/project.pbxproj index 4e834d1f..f5989365 100644 --- a/apps/mobile/apps/client/ios/Runner.xcodeproj/project.pbxproj +++ b/apps/mobile/apps/client/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 221E00B70DE845BE3D50D0A0 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 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 = ""; }; @@ -94,6 +96,7 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + 221E00B70DE845BE3D50D0A0 /* GoogleService-Info.plist */, ); sourceTree = ""; }; @@ -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; }; diff --git a/apps/mobile/apps/client/ios/Runner/GoogleService-Info.plist b/apps/mobile/apps/client/ios/Runner/GoogleService-Info.plist new file mode 100644 index 00000000..fbbfcc69 --- /dev/null +++ b/apps/mobile/apps/client/ios/Runner/GoogleService-Info.plist @@ -0,0 +1,36 @@ + + + + + CLIENT_ID + 933560802882-jqpv1l3gjmi3m87b2gu1iq4lg46lkdfg.apps.googleusercontent.com + REVERSED_CLIENT_ID + com.googleusercontent.apps.933560802882-jqpv1l3gjmi3m87b2gu1iq4lg46lkdfg + ANDROID_CLIENT_ID + 933560802882-ikdfv3o5f47g36qqgvfq55o4m19n7gk4.apps.googleusercontent.com + API_KEY + AIzaSyDyEXkzZAWpXXe4dAesYaZflt5BEtMn9tA + GCM_SENDER_ID + 933560802882 + PLIST_VERSION + 1 + BUNDLE_ID + com.krowwithus.client + PROJECT_ID + krow-workforce-dev + STORAGE_BUCKET + krow-workforce-dev.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:933560802882:ios:d2b6d743608e2a527757db + + \ No newline at end of file diff --git a/apps/mobile/apps/client/lib/firebase_options.dart b/apps/mobile/apps/client/lib/firebase_options.dart new file mode 100644 index 00000000..08a57ddc --- /dev/null +++ b/apps/mobile/apps/client/lib/firebase_options.dart @@ -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', + ); +} diff --git a/apps/mobile/apps/client/lib/main.dart b/apps/mobile/apps/client/lib/main.dart index 8acdd045..362fe8b3 100644 --- a/apps/mobile/apps/client/lib/main.dart +++ b/apps/mobile/apps/client/lib/main.dart @@ -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())); } diff --git a/apps/mobile/apps/client/pubspec.yaml b/apps/mobile/apps/client/pubspec.yaml index 2221f485..1d5e21d6 100644 --- a/apps/mobile/apps/client/pubspec.yaml +++ b/apps/mobile/apps/client/pubspec.yaml @@ -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 diff --git a/apps/mobile/apps/client/web/index.html b/apps/mobile/apps/client/web/index.html index 0b6cda5a..998286cb 100644 --- a/apps/mobile/apps/client/web/index.html +++ b/apps/mobile/apps/client/web/index.html @@ -33,6 +33,29 @@ + diff --git a/apps/mobile/apps/staff/android/app/google-services.json b/apps/mobile/apps/staff/android/app/google-services.json index 42bb1f02..f4d57e10 100644 --- a/apps/mobile/apps/staff/android/app/google-services.json +++ b/apps/mobile/apps/staff/android/app/google-services.json @@ -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", diff --git a/apps/mobile/apps/staff/ios/Runner.xcodeproj/project.pbxproj b/apps/mobile/apps/staff/ios/Runner.xcodeproj/project.pbxproj index 1569b385..8243a8b5 100644 --- a/apps/mobile/apps/staff/ios/Runner.xcodeproj/project.pbxproj +++ b/apps/mobile/apps/staff/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 9F0B07DEC91B141354438F79 /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -94,6 +96,7 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + 9F0B07DEC91B141354438F79 /* GoogleService-Info.plist */, ); sourceTree = ""; }; @@ -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; }; diff --git a/apps/mobile/apps/staff/ios/Runner/GoogleService-Info.plist b/apps/mobile/apps/staff/ios/Runner/GoogleService-Info.plist new file mode 100644 index 00000000..4a0f6b5d --- /dev/null +++ b/apps/mobile/apps/staff/ios/Runner/GoogleService-Info.plist @@ -0,0 +1,36 @@ + + + + + CLIENT_ID + 933560802882-29olj9ku64jbe9h7flinha6hbi8qrluh.apps.googleusercontent.com + REVERSED_CLIENT_ID + com.googleusercontent.apps.933560802882-29olj9ku64jbe9h7flinha6hbi8qrluh + ANDROID_CLIENT_ID + 933560802882-ikdfv3o5f47g36qqgvfq55o4m19n7gk4.apps.googleusercontent.com + API_KEY + AIzaSyDyEXkzZAWpXXe4dAesYaZflt5BEtMn9tA + GCM_SENDER_ID + 933560802882 + PLIST_VERSION + 1 + BUNDLE_ID + com.krowwithus.staff + PROJECT_ID + krow-workforce-dev + STORAGE_BUCKET + krow-workforce-dev.firebasestorage.app + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + 1:933560802882:ios:fa584205b356de937757db + + \ No newline at end of file diff --git a/apps/mobile/apps/staff/lib/firebase_options.dart b/apps/mobile/apps/staff/lib/firebase_options.dart new file mode 100644 index 00000000..343d9f5e --- /dev/null +++ b/apps/mobile/apps/staff/lib/firebase_options.dart @@ -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', + ); +} diff --git a/apps/mobile/apps/staff/lib/main.dart b/apps/mobile/apps/staff/lib/main.dart index f4c215c6..050ae079 100644 --- a/apps/mobile/apps/staff/lib/main.dart +++ b/apps/mobile/apps/staff/lib/main.dart @@ -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())); } diff --git a/apps/mobile/apps/staff/pubspec.yaml b/apps/mobile/apps/staff/pubspec.yaml index 7b479547..29c204ad 100644 --- a/apps/mobile/apps/staff/pubspec.yaml +++ b/apps/mobile/apps/staff/pubspec.yaml @@ -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: diff --git a/apps/mobile/apps/staff/web/index.html b/apps/mobile/apps/staff/web/index.html index e03e65a3..ff5fd9b9 100644 --- a/apps/mobile/apps/staff/web/index.html +++ b/apps/mobile/apps/staff/web/index.html @@ -34,5 +34,28 @@ + diff --git a/apps/mobile/packages/core/lib/src/presentation/widgets/web_mobile_frame.dart b/apps/mobile/packages/core/lib/src/presentation/widgets/web_mobile_frame.dart index 9c636294..0c5b1d00 100644 --- a/apps/mobile/packages/core/lib/src/presentation/widgets/web_mobile_frame.dart +++ b/apps/mobile/packages/core/lib/src/presentation/widgets/web_mobile_frame.dart @@ -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: [ // 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: [ + 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( - 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: [ // The actual app + status bar Column( - children: [ + children: [ // 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: [ // 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: [ 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( + color: UiColors.black.withOpacity(0.2), + blurRadius: 4, + spreadRadius: 1, + ), + ], + ), + ), + ), + ), ], ), ), diff --git a/apps/mobile/packages/core/pubspec.yaml b/apps/mobile/packages/core/pubspec.yaml index 1b14ddda..f0a02c12 100644 --- a/apps/mobile/packages/core/pubspec.yaml +++ b/apps/mobile/packages/core/pubspec.yaml @@ -11,3 +11,5 @@ environment: dependencies: flutter: sdk: flutter + design_system: + path: ../design_system diff --git a/apps/mobile/packages/design_system/lib/src/ui_icons.dart b/apps/mobile/packages/design_system/lib/src/ui_icons.dart index e035bf63..6acff6a9 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_icons.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_icons.dart @@ -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; diff --git a/apps/mobile/packages/design_system/lib/src/ui_theme.dart b/apps/mobile/packages/design_system/lib/src/ui_theme.dart index ae97f1b6..2b098529 100644 --- a/apps/mobile/packages/design_system/lib/src/ui_theme.dart +++ b/apps/mobile/packages/design_system/lib/src/ui_theme.dart @@ -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. diff --git a/apps/mobile/packages/design_system/lib/src/widgets/ui_button.dart b/apps/mobile/packages/design_system/lib/src/widgets/ui_button.dart index 7867798c..68f16e49 100644 --- a/apps/mobile/packages/design_system/lib/src/widgets/ui_button.dart +++ b/apps/mobile/packages/design_system/lib/src/widgets/ui_button.dart @@ -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, diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index fc763e64..3dc41679 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -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'; diff --git a/apps/mobile/packages/domain/lib/src/adapters/shifts/shift_adapter.dart b/apps/mobile/packages/domain/lib/src/adapters/shifts/shift_adapter.dart index 07cab44a..6022d327 100644 --- a/apps/mobile/packages/domain/lib/src/adapters/shifts/shift_adapter.dart +++ b/apps/mobile/packages/domain/lib/src/adapters/shifts/shift_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, + ); + } } + diff --git a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/coverage_shift.dart b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/coverage_shift.dart new file mode 100644 index 00000000..afc10d60 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/coverage_shift.dart @@ -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 workers; + + /// Calculates the coverage percentage for this shift. + int get coveragePercent { + if (workersNeeded == 0) return 0; + return ((workers.length / workersNeeded) * 100).round(); + } + + @override + List get props => [ + id, + title, + location, + startTime, + workersNeeded, + date, + workers, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/coverage_stats.dart b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/coverage_stats.dart new file mode 100644 index 00000000..580116a9 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/coverage_stats.dart @@ -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 get props => [ + totalNeeded, + totalConfirmed, + checkedIn, + enRoute, + late, + ]; +} diff --git a/apps/mobile/packages/domain/lib/src/entities/coverage_domain/coverage_worker.dart b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/coverage_worker.dart new file mode 100644 index 00000000..3ade4d9d --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/coverage_domain/coverage_worker.dart @@ -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 get props => [name, status, checkInTime]; +} diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart index 825917ff..bc522cb9 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/pages/billing_page.dart @@ -200,6 +200,8 @@ class _BillingViewState extends State { const SpendingBreakdownCard(), if (state.invoiceHistory.isEmpty) _buildEmptyState(context) else InvoiceHistorySection(invoices: state.invoiceHistory), + + const SizedBox(height: UiConstants.space32), ], ), ); diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart index 305f65e9..cfecec36 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/data/repositories_impl/coverage_repository_impl.dart @@ -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> 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 []; } 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 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 status, ) { if (status is dc.Known) { 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) { diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository.dart index 6d7de8ba..f5c340b3 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/repositories/coverage_repository.dart @@ -1,4 +1,4 @@ -import '../ui_entities/coverage_entities.dart'; +import 'package:krow_domain/krow_domain.dart'; /// Repository interface for coverage-related operations. /// diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/ui_entities/coverage_entities.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/ui_entities/coverage_entities.dart deleted file mode 100644 index bb9249c9..00000000 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/ui_entities/coverage_entities.dart +++ /dev/null @@ -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 workers; - - /// Calculates the coverage percentage for this shift. - int get coveragePercent { - if (workersNeeded == 0) return 0; - return ((workers.length / workersNeeded) * 100).round(); - } - - @override - List get props => [ - 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 get props => [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 get props => [ - totalNeeded, - totalConfirmed, - checkedIn, - enRoute, - late, - ]; -} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_coverage_stats_usecase.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_coverage_stats_usecase.dart index 00cb7c1d..a2fa4a50 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_coverage_stats_usecase.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_coverage_stats_usecase.dart @@ -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. /// diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_shifts_for_date_usecase.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_shifts_for_date_usecase.dart index da84506b..1b17c969 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_shifts_for_date_usecase.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/domain/usecases/get_shifts_for_date_usecase.dart @@ -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. /// diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_bloc.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_bloc.dart index d8a0a8c3..c218e9a5 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_bloc.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_bloc.dart @@ -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'; diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_state.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_state.dart index 9ca35dad..e6b99656 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_state.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/blocs/coverage_state.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 { diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart index 56f87c69..31e3fd42 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_quick_stats.dart @@ -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. /// diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart index 7ec0c0c5..504828dd 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart @@ -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( diff --git a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart index fe9274b8..1dfa8353 100644 --- a/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart +++ b/apps/mobile/packages/features/client/home/lib/src/presentation/widgets/reorder_widget.dart @@ -43,14 +43,11 @@ class ReorderWidget extends StatelessWidget { ), if (subtitle != null) ...[ 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( - 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({ - '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({ + 'orderId': order.orderId, + 'title': order.title, + 'location': order.location, + 'hourlyRate': order.hourlyRate, + 'hours': order.hours, + 'workers': order.workers, + 'type': order.type, + }), ), ], ), diff --git a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart index f644caf3..5d4deac1 100644 --- a/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart +++ b/apps/mobile/packages/features/client/settings/lib/src/presentation/widgets/client_settings_page/settings_profile_header.dart @@ -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, diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/pages/view_orders_page.dart b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/pages/view_orders_page.dart index 27ca4dc2..fd256e8c 100644 --- a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/pages/view_orders_page.dart +++ b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/pages/view_orders_page.dart @@ -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 { } return Scaffold( - body: Stack( - children: [ - // Background Gradient - Container( - decoration: const BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [UiColors.bgSecondary, UiColors.white], - stops: [0.0, 0.3], - ), + body: SafeArea( + child: Column( + children: [ + // Header + Filter + Calendar (Sticky behavior) + ViewOrdersHeader( + state: state, + calendarDays: calendarDays, ), - ), - - SafeArea( - child: Column( - children: [ - // 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: [ - if (filteredOrders.isNotEmpty) - Padding( - padding: const EdgeInsets.only( - bottom: UiConstants.space3, - ), - child: Row( - children: [ - 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: [ + if (filteredOrders.isNotEmpty) + Padding( + padding: const EdgeInsets.only( + bottom: UiConstants.space3, ), - ], + child: Row( + children: [ + 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 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: [ - // Top Bar - Padding( - padding: const EdgeInsets.fromLTRB( - UiConstants.space5, - UiConstants.space5, - UiConstants.space5, - UiConstants.space3, - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - 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: [ - _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: [ - IconButton( - icon: const Icon( - UiIcons.chevronLeft, - size: 20, - color: UiColors.iconSecondary, - ), - onPressed: () => BlocProvider.of( - 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( - 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( - 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( - color: UiColors.primary.withValues( - alpha: 0.25, - ), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ] - : null, - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - 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) ...[ - 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(context).selectFilterTab(tabId), - child: Column( - children: [ - 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, diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart index 6886cfe0..76416e5d 100644 --- a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart +++ b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_order_card.dart @@ -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 { children: [ Row( children: [ - 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, ), ], diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_orders_filter_tab.dart b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_orders_filter_tab.dart new file mode 100644 index 00000000..661face0 --- /dev/null +++ b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_orders_filter_tab.dart @@ -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(context).selectFilterTab(tabId), + child: Column( + children: [ + 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), + ], + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_orders_header.dart b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_orders_header.dart new file mode 100644 index 00000000..45e72f93 --- /dev/null +++ b/apps/mobile/packages/features/client/view_orders/lib/src/presentation/widgets/view_orders_header.dart @@ -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 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: [ + // Top Bar + Padding( + padding: const EdgeInsets.fromLTRB( + UiConstants.space5, + UiConstants.space5, + UiConstants.space5, + UiConstants.space3, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + 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: [ + 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: [ + IconButton( + icon: const Icon( + UiIcons.chevronLeft, + size: 20, + color: UiColors.iconSecondary, + ), + onPressed: () => BlocProvider.of( + 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( + 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( + 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( + color: UiColors.primary.withValues( + alpha: 0.25, + ), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ] + : null, + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + 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) ...[ + 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), + ], + ), + ), + ), + ); + } +} diff --git a/apps/mobile/packages/features/client/view_orders/pubspec.yaml b/apps/mobile/packages/features/client/view_orders/pubspec.yaml index 5c419aa9..46182d70 100644 --- a/apps/mobile/packages/features/client/view_orders/pubspec.yaml +++ b/apps/mobile/packages/features/client/view_orders/pubspec.yaml @@ -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: diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart index b58ed1bf..0192487c 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart @@ -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. /// diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input.dart index 01be5bf4..9ad647f3 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input.dart @@ -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 createState() => _PhoneInputState(); +} + +class _PhoneInputState extends State { + void _handlePhoneChanged(String value) { + if (!mounted) return; + + final AuthBloc bloc = context.read(); + 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( - 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, + ), ], ); } diff --git a/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart b/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart index 508e350a..6769036f 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/data/repositories/home_repository_impl.dart @@ -32,28 +32,40 @@ class HomeRepositoryImpl implements HomeRepository { Future> _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 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> 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, ); } diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart index 1cbb51fc..a9b3f169 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/pages/worker_home_page.dart @@ -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. /// diff --git a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart index f2a95f0d..f223bbcd 100644 --- a/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart +++ b/apps/mobile/packages/features/staff/home/lib/src/presentation/widgets/shift_card.dart @@ -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 { ? 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), diff --git a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart index c120e1c3..76301fd1 100644 --- a/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart +++ b/apps/mobile/packages/features/staff/shifts/lib/src/data/repositories_impl/shifts_repository_impl.dart @@ -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> 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> getPendingAssignments() async { - return _fetchApplications(dc.ApplicationStatus.PENDING); + return _fetchApplications([dc.ApplicationStatus.PENDING]); } @override Future> 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> _fetchApplications( - dc.ApplicationStatus status, { + List 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 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 acceptShift(String shiftId) async { - await _updateApplicationStatus(shiftId, dc.ApplicationStatus.ACCEPTED); + await _updateApplicationStatus(shiftId, dc.ApplicationStatus.CONFIRMED); } @override diff --git a/docs/QA_TESTING_CHECKLIST.md b/docs/QA_TESTING_CHECKLIST.md new file mode 100644 index 00000000..49fef7e4 --- /dev/null +++ b/docs/QA_TESTING_CHECKLIST.md @@ -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 + +--- diff --git a/internal/launchpad/index.html b/internal/launchpad/index.html index 55c7fb9e..c8dbdcc2 100644 --- a/internal/launchpad/index.html +++ b/internal/launchpad/index.html @@ -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 = `

Mermaid Error: ${err.message}

`; + pre.replaceWith(errorDiv); + } + } } catch (error) { console.error('Error loading document:', error); documentContainer.innerHTML = `