diff --git a/.gitignore b/.gitignore index c3c5a87f..ec858049 100644 --- a/.gitignore +++ b/.gitignore @@ -43,6 +43,7 @@ lerna-debug.log* *.temp tmp/ temp/ +scripts/issues-to-create.md # ============================================================================== # SECURITY (CRITICAL) diff --git a/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index de98cbea..f3808646 100644 --- a/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/apps/mobile/apps/client/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -15,6 +15,11 @@ import io.flutter.embedding.engine.FlutterEngine; public final class GeneratedPluginRegistrant { private static final String TAG = "GeneratedPluginRegistrant"; public static void registerWith(@NonNull FlutterEngine flutterEngine) { + try { + flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.appcheck.FlutterFirebaseAppCheckPlugin()); } catch (Exception e) { @@ -30,6 +35,16 @@ public final class GeneratedPluginRegistrant { } catch (Exception e) { Log.e(TAG, "Error registering plugin firebase_core, io.flutter.plugins.firebase.core.FlutterFirebaseCorePlugin", e); } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin flutter_plugin_android_lifecycle, io.flutter.plugins.flutter_plugin_android_lifecycle.FlutterAndroidLifecyclePlugin", e); + } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin image_picker_android, io.flutter.plugins.imagepicker.ImagePickerPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin()); } catch (Exception e) { diff --git a/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m b/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m index 69b16696..8b0a7da5 100644 --- a/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m +++ b/apps/mobile/apps/client/ios/Runner/GeneratedPluginRegistrant.m @@ -6,6 +6,12 @@ #import "GeneratedPluginRegistrant.h" +#if __has_include() +#import +#else +@import file_picker; +#endif + #if __has_include() #import #else @@ -24,6 +30,12 @@ @import firebase_core; #endif +#if __has_include() +#import +#else +@import image_picker_ios; +#endif + #if __has_include() #import #else @@ -39,9 +51,11 @@ @implementation GeneratedPluginRegistrant + (void)registerWithRegistry:(NSObject*)registry { + [FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]]; [FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]]; [FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]]; [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; + [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; } diff --git a/apps/mobile/apps/client/linux/flutter/generated_plugin_registrant.cc b/apps/mobile/apps/client/linux/flutter/generated_plugin_registrant.cc index f6f23bfe..7299b5cf 100644 --- a/apps/mobile/apps/client/linux/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/apps/client/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/apps/mobile/apps/client/linux/flutter/generated_plugins.cmake b/apps/mobile/apps/client/linux/flutter/generated_plugins.cmake index f16b4c34..786ff5c2 100644 --- a/apps/mobile/apps/client/linux/flutter/generated_plugins.cmake +++ b/apps/mobile/apps/client/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux url_launcher_linux ) diff --git a/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift index c4ba9dcf..30780dc6 100644 --- a/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/apps/client/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import file_picker +import file_selector_macos import firebase_app_check import firebase_auth import firebase_core @@ -12,6 +14,8 @@ import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) diff --git a/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc b/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc index 869eecae..3a3369d4 100644 --- a/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/apps/client/windows/flutter/generated_plugin_registrant.cc @@ -6,11 +6,14 @@ #include "generated_plugin_registrant.h" +#include #include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FirebaseAuthPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); FirebaseCorePluginCApiRegisterWithRegistrar( diff --git a/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake b/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake index 7ba8383b..b9b24c8b 100644 --- a/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake +++ b/apps/mobile/apps/client/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows firebase_auth firebase_core url_launcher_windows diff --git a/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java b/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java index ee04ee9a..fbdc8215 100644 --- a/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java +++ b/apps/mobile/apps/staff/android/app/src/main/java/io/flutter/plugins/GeneratedPluginRegistrant.java @@ -15,6 +15,11 @@ import io.flutter.embedding.engine.FlutterEngine; public final class GeneratedPluginRegistrant { private static final String TAG = "GeneratedPluginRegistrant"; public static void registerWith(@NonNull FlutterEngine flutterEngine) { + try { + flutterEngine.getPlugins().add(new com.mr.flutter.plugin.filepicker.FilePickerPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin file_picker, com.mr.flutter.plugin.filepicker.FilePickerPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.firebase.appcheck.FlutterFirebaseAppCheckPlugin()); } catch (Exception e) { @@ -45,6 +50,11 @@ public final class GeneratedPluginRegistrant { } catch (Exception e) { Log.e(TAG, "Error registering plugin google_maps_flutter_android, io.flutter.plugins.googlemaps.GoogleMapsPlugin", e); } + try { + flutterEngine.getPlugins().add(new io.flutter.plugins.imagepicker.ImagePickerPlugin()); + } catch (Exception e) { + Log.e(TAG, "Error registering plugin image_picker_android, io.flutter.plugins.imagepicker.ImagePickerPlugin", e); + } try { flutterEngine.getPlugins().add(new io.flutter.plugins.pathprovider.PathProviderPlugin()); } catch (Exception e) { diff --git a/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m b/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m index 7a704337..e8a688bb 100644 --- a/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m +++ b/apps/mobile/apps/staff/ios/Runner/GeneratedPluginRegistrant.m @@ -6,6 +6,12 @@ #import "GeneratedPluginRegistrant.h" +#if __has_include() +#import +#else +@import file_picker; +#endif + #if __has_include() #import #else @@ -36,6 +42,12 @@ @import google_maps_flutter_ios; #endif +#if __has_include() +#import +#else +@import image_picker_ios; +#endif + #if __has_include() #import #else @@ -57,11 +69,13 @@ @implementation GeneratedPluginRegistrant + (void)registerWithRegistry:(NSObject*)registry { + [FilePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FilePickerPlugin"]]; [FLTFirebaseAppCheckPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAppCheckPlugin"]]; [FLTFirebaseAuthPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseAuthPlugin"]]; [FLTFirebaseCorePlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTFirebaseCorePlugin"]]; [GeolocatorPlugin registerWithRegistrar:[registry registrarForPlugin:@"GeolocatorPlugin"]]; [FLTGoogleMapsPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTGoogleMapsPlugin"]]; + [FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]]; [PermissionHandlerPlugin registerWithRegistrar:[registry registrarForPlugin:@"PermissionHandlerPlugin"]]; [SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]]; [URLLauncherPlugin registerWithRegistrar:[registry registrarForPlugin:@"URLLauncherPlugin"]]; diff --git a/apps/mobile/apps/staff/lib/main.dart b/apps/mobile/apps/staff/lib/main.dart index 91f1e952..440dba19 100644 --- a/apps/mobile/apps/staff/lib/main.dart +++ b/apps/mobile/apps/staff/lib/main.dart @@ -5,22 +5,23 @@ import 'package:design_system/design_system.dart'; import 'package:firebase_core/firebase_core.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:marionette_flutter/marionette_flutter.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; import 'package:krow_data_connect/krow_data_connect.dart'; import 'package:krowwithus_staff/firebase_options.dart'; +import 'package:marionette_flutter/marionette_flutter.dart'; import 'package:staff_authentication/staff_authentication.dart' as staff_authentication; import 'package:staff_main/staff_main.dart' as staff_main; -import 'package:krow_core/core.dart'; import 'src/widgets/session_listener.dart'; void main() async { - final bool isFlutterTest = - !kIsWeb ? Platform.environment.containsKey('FLUTTER_TEST') : false; + final bool isFlutterTest = !kIsWeb + ? Platform.environment.containsKey('FLUTTER_TEST') + : false; if (kDebugMode && !isFlutterTest) { MarionetteBinding.ensureInitialized( MarionetteConfiguration( @@ -46,7 +47,10 @@ void main() async { // Initialize session listener for Firebase Auth state changes DataConnectService.instance.initializeAuthListener( - allowedRoles: ['STAFF', 'BOTH'], // Only allow users with STAFF or BOTH roles + allowedRoles: [ + 'STAFF', + 'BOTH', + ], // Only allow users with STAFF or BOTH roles ); runApp( @@ -60,11 +64,11 @@ void main() async { /// The main application module. class AppModule extends Module { @override - List get imports => - [ - core_localization.LocalizationModule(), - staff_authentication.StaffAuthenticationModule(), - ]; + List get imports => [ + CoreModule(), + core_localization.LocalizationModule(), + staff_authentication.StaffAuthenticationModule(), + ]; @override void routes(RouteManager r) { diff --git a/apps/mobile/apps/staff/linux/flutter/generated_plugin_registrant.cc b/apps/mobile/apps/staff/linux/flutter/generated_plugin_registrant.cc index f6f23bfe..7299b5cf 100644 --- a/apps/mobile/apps/staff/linux/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/apps/staff/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/apps/mobile/apps/staff/linux/flutter/generated_plugins.cmake b/apps/mobile/apps/staff/linux/flutter/generated_plugins.cmake index f16b4c34..786ff5c2 100644 --- a/apps/mobile/apps/staff/linux/flutter/generated_plugins.cmake +++ b/apps/mobile/apps/staff/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux url_launcher_linux ) diff --git a/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift index 83c9214f..56b4b1e5 100644 --- a/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/apps/staff/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import file_picker +import file_selector_macos import firebase_app_check import firebase_auth import firebase_core @@ -13,6 +15,8 @@ import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) diff --git a/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc b/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc index 148eb231..f06cf63c 100644 --- a/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/apps/staff/windows/flutter/generated_plugin_registrant.cc @@ -6,6 +6,7 @@ #include "generated_plugin_registrant.h" +#include #include #include #include @@ -13,6 +14,8 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FirebaseAuthPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); FirebaseCorePluginCApiRegisterWithRegistrar( diff --git a/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake b/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake index 333a9eb4..e3928570 100644 --- a/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake +++ b/apps/mobile/apps/staff/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows firebase_auth firebase_core geolocator_windows diff --git a/apps/mobile/config.dev.json b/apps/mobile/config.dev.json index 95c65c67..a6d85eec 100644 --- a/apps/mobile/config.dev.json +++ b/apps/mobile/config.dev.json @@ -1,3 +1,4 @@ { - "GOOGLE_MAPS_API_KEY": "AIzaSyAyRS9I4xxoVPAX91RJvWJHszB3ZY3-IC0" + "GOOGLE_MAPS_API_KEY": "AIzaSyAyRS9I4xxoVPAX91RJvWJHszB3ZY3-IC0", + "CORE_API_BASE_URL": "https://krow-core-api-e3g6witsvq-uc.a.run.app" } \ No newline at end of file diff --git a/apps/mobile/packages/core/lib/core.dart b/apps/mobile/packages/core/lib/core.dart index 0aa4de1d..e5dff061 100644 --- a/apps/mobile/packages/core/lib/core.dart +++ b/apps/mobile/packages/core/lib/core.dart @@ -1,5 +1,7 @@ library; +export 'src/core_module.dart'; + export 'src/domain/arguments/usecase_argument.dart'; export 'src/domain/usecases/usecase.dart'; export 'src/utils/date_time_utils.dart'; @@ -8,3 +10,22 @@ export 'src/presentation/mixins/bloc_error_handler.dart'; export 'src/presentation/observers/core_bloc_observer.dart'; export 'src/config/app_config.dart'; export 'src/routing/routing.dart'; +export 'src/services/api_service/api_service.dart'; +export 'src/services/api_service/dio_client.dart'; + +// Core API Services +export 'src/services/api_service/core_api_services/core_api_endpoints.dart'; +export 'src/services/api_service/core_api_services/file_upload/file_upload_service.dart'; +export 'src/services/api_service/core_api_services/file_upload/file_upload_response.dart'; +export 'src/services/api_service/core_api_services/signed_url/signed_url_service.dart'; +export 'src/services/api_service/core_api_services/signed_url/signed_url_response.dart'; +export 'src/services/api_service/core_api_services/llm/llm_service.dart'; +export 'src/services/api_service/core_api_services/llm/llm_response.dart'; +export 'src/services/api_service/core_api_services/verification/verification_service.dart'; +export 'src/services/api_service/core_api_services/verification/verification_response.dart'; + +// Device Services +export 'src/services/device/camera/camera_service.dart'; +export 'src/services/device/gallery/gallery_service.dart'; +export 'src/services/device/file/file_picker_service.dart'; +export 'src/services/device/file_upload/device_file_upload_service.dart'; diff --git a/apps/mobile/packages/core/lib/src/config/app_config.dart b/apps/mobile/packages/core/lib/src/config/app_config.dart index 9bf56394..6752f3c6 100644 --- a/apps/mobile/packages/core/lib/src/config/app_config.dart +++ b/apps/mobile/packages/core/lib/src/config/app_config.dart @@ -5,5 +5,12 @@ class AppConfig { AppConfig._(); /// The Google Maps API key. - static const String googleMapsApiKey = String.fromEnvironment('GOOGLE_MAPS_API_KEY'); + static const String googleMapsApiKey = String.fromEnvironment( + 'GOOGLE_MAPS_API_KEY', + ); + + /// The base URL for the Core API. + static const String coreApiBaseUrl = String.fromEnvironment( + 'CORE_API_BASE_URL', + ); } diff --git a/apps/mobile/packages/core/lib/src/core_module.dart b/apps/mobile/packages/core/lib/src/core_module.dart new file mode 100644 index 00000000..bd782a8a --- /dev/null +++ b/apps/mobile/packages/core/lib/src/core_module.dart @@ -0,0 +1,48 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import '../core.dart'; + +/// A module that provides core services and shared dependencies. +/// +/// This module should be imported by the root [AppModule] to make +/// core services available globally as singletons. +class CoreModule extends Module { + @override + void exportedBinds(Injector i) { + // 1. Register the base HTTP client + i.addSingleton(() => DioClient()); + + // 2. Register the base API service + i.addSingleton(() => ApiService(i.get())); + + // 3. Register Core API Services (Orchestrators) + i.addSingleton( + () => FileUploadService(i.get()), + ); + i.addSingleton( + () => SignedUrlService(i.get()), + ); + i.addSingleton( + () => VerificationService(i.get()), + ); + i.addSingleton(() => LlmService(i.get())); + + // 4. Register Device dependency + i.addSingleton(() => ImagePicker()); + + // 5. Register Device Services + i.addSingleton(() => CameraService(i.get())); + i.addSingleton(() => GalleryService(i.get())); + i.addSingleton(FilePickerService.new); + i.addSingleton( + () => DeviceFileUploadService( + cameraService: i.get(), + galleryService: i.get(), + apiUploadService: i.get(), + ), + ); + } +} diff --git a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart index 7b8a9f25..5d62480c 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/navigator.dart @@ -196,7 +196,22 @@ extension StaffNavigator on IModularNavigator { /// /// Record sizing and appearance information for uniform allocation. void toAttire() { - pushNamed(StaffPaths.attire); + navigate(StaffPaths.attire); + } + + /// Pushes the attire capture page. + /// + /// Parameters: + /// * [item] - The attire item to capture + /// * [initialPhotoUrl] - Optional initial photo URL + void toAttireCapture({required AttireItem item, String? initialPhotoUrl}) { + navigate( + StaffPaths.attireCapture, + arguments: { + 'item': item, + 'initialPhotoUrl': initialPhotoUrl, + }, + ); } // ========================================================================== diff --git a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart index f0a602ab..4929e1a0 100644 --- a/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart +++ b/apps/mobile/packages/core/lib/src/routing/staff/route_paths.dart @@ -152,6 +152,9 @@ class StaffPaths { /// Record sizing and appearance information for uniform allocation. static const String attire = '/worker-main/attire/'; + /// Attire capture page. + static const String attireCapture = '/worker-main/attire/capture/'; + // ========================================================================== // COMPLIANCE & DOCUMENTS // ========================================================================== diff --git a/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart new file mode 100644 index 00000000..db1119c9 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/api_service.dart @@ -0,0 +1,127 @@ +import 'package:dio/dio.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// A service that handles HTTP communication using the [Dio] client. +/// +/// This class provides a wrapper around [Dio]'s methods to handle +/// response parsing and error handling in a consistent way. +class ApiService implements BaseApiService { + /// Creates an [ApiService] with the given [Dio] instance. + ApiService(this._dio); + + /// The underlying [Dio] client used for network requests. + final Dio _dio; + + /// Performs a GET request to the specified [endpoint]. + @override + Future get( + String endpoint, { + Map? params, + }) async { + try { + final Response response = await _dio.get( + endpoint, + queryParameters: params, + ); + return _handleResponse(response); + } on DioException catch (e) { + return _handleError(e); + } + } + + /// Performs a POST request to the specified [endpoint]. + @override + Future post( + String endpoint, { + dynamic data, + Map? params, + }) async { + try { + final Response response = await _dio.post( + endpoint, + data: data, + queryParameters: params, + ); + return _handleResponse(response); + } on DioException catch (e) { + return _handleError(e); + } + } + + /// Performs a PUT request to the specified [endpoint]. + @override + Future put( + String endpoint, { + dynamic data, + Map? params, + }) async { + try { + final Response response = await _dio.put( + endpoint, + data: data, + queryParameters: params, + ); + return _handleResponse(response); + } on DioException catch (e) { + return _handleError(e); + } + } + + /// Performs a PATCH request to the specified [endpoint]. + @override + Future patch( + String endpoint, { + dynamic data, + Map? params, + }) async { + try { + final Response response = await _dio.patch( + endpoint, + data: data, + queryParameters: params, + ); + return _handleResponse(response); + } on DioException catch (e) { + return _handleError(e); + } + } + + /// Extracts [ApiResponse] from a successful [Response]. + ApiResponse _handleResponse(Response response) { + return ApiResponse( + code: response.statusCode?.toString() ?? '200', + message: response.data['message']?.toString() ?? 'Success', + data: response.data, + ); + } + + /// Extracts [ApiResponse] from a [DioException]. + ApiResponse _handleError(DioException e) { + if (e.response?.data is Map) { + final Map body = + e.response!.data as Map; + return ApiResponse( + code: + body['code']?.toString() ?? + e.response?.statusCode?.toString() ?? + 'error', + message: body['message']?.toString() ?? e.message ?? 'Error occurred', + data: body['data'], + errors: _parseErrors(body['errors']), + ); + } + return ApiResponse( + code: e.response?.statusCode?.toString() ?? 'error', + message: e.message ?? 'Unknown error', + errors: {'exception': e.type.toString()}, + ); + } + + /// Helper to parse the errors map from various possible formats. + Map _parseErrors(dynamic errors) { + if (errors is Map) { + return Map.from(errors); + } + return const {}; + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/core_api_endpoints.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/core_api_endpoints.dart new file mode 100644 index 00000000..1c2a80cd --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/core_api_endpoints.dart @@ -0,0 +1,33 @@ +import '../../../config/app_config.dart'; + +/// Constants for Core API endpoints. +class CoreApiEndpoints { + CoreApiEndpoints._(); + + /// The base URL for the Core API. + static const String baseUrl = AppConfig.coreApiBaseUrl; + + /// Upload a file. + static const String uploadFile = '$baseUrl/core/upload-file'; + + /// Create a signed URL for a file. + static const String createSignedUrl = '$baseUrl/core/create-signed-url'; + + /// Invoke a Large Language Model. + static const String invokeLlm = '$baseUrl/core/invoke-llm'; + + /// Root for verification operations. + static const String verifications = '$baseUrl/core/verifications'; + + /// Get status of a verification job. + static String verificationStatus(String id) => + '$baseUrl/core/verifications/$id'; + + /// Review a verification decision. + static String verificationReview(String id) => + '$baseUrl/core/verifications/$id/review'; + + /// Retry a verification job. + static String verificationRetry(String id) => + '$baseUrl/core/verifications/$id/retry'; +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_response.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_response.dart new file mode 100644 index 00000000..941fe01d --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_response.dart @@ -0,0 +1,54 @@ +/// Response model for file upload operation. +class FileUploadResponse { + /// Creates a [FileUploadResponse]. + const FileUploadResponse({ + required this.fileUri, + required this.contentType, + required this.size, + required this.bucket, + required this.path, + this.requestId, + }); + + /// Factory to create [FileUploadResponse] from JSON. + factory FileUploadResponse.fromJson(Map json) { + return FileUploadResponse( + fileUri: json['fileUri'] as String, + contentType: json['contentType'] as String, + size: json['size'] as int, + bucket: json['bucket'] as String, + path: json['path'] as String, + requestId: json['requestId'] as String?, + ); + } + + /// The Cloud Storage URI of the uploaded file. + final String fileUri; + + /// The MIME type of the file. + final String contentType; + + /// The size of the file in bytes. + final int size; + + /// The bucket where the file was uploaded. + final String bucket; + + /// The path within the bucket. + final String path; + + /// The unique request ID from the server. + final String? requestId; + + /// Converts the response to a JSON map. + Map toJson() { + return { + 'fileUri': fileUri, + 'contentType': contentType, + 'size': size, + 'bucket': bucket, + 'path': path, + 'requestId': requestId, + }; + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_service.dart new file mode 100644 index 00000000..09dc2854 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/file_upload/file_upload_service.dart @@ -0,0 +1,38 @@ +import 'package:dio/dio.dart'; +import 'package:krow_domain/krow_domain.dart'; +import '../core_api_endpoints.dart'; +import 'file_upload_response.dart'; + +/// Service for uploading files to the Core API. +class FileUploadService extends BaseCoreService { + /// Creates a [FileUploadService]. + FileUploadService(super.api); + + /// Uploads a file with optional visibility and category. + /// + /// [filePath] is the local path to the file. + /// [visibility] can be [FileVisibility.public] or [FileVisibility.private]. + /// [category] is an optional metadata field. + Future uploadFile({ + required String filePath, + required String fileName, + FileVisibility visibility = FileVisibility.private, + String? category, + }) async { + final ApiResponse res = await action(() async { + final FormData formData = FormData.fromMap({ + 'file': await MultipartFile.fromFile(filePath, filename: fileName), + 'visibility': visibility.value, + if (category != null) 'category': category, + }); + + return api.post(CoreApiEndpoints.uploadFile, data: formData); + }); + + if (res.code.startsWith('2')) { + return FileUploadResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_response.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_response.dart new file mode 100644 index 00000000..add3c331 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_response.dart @@ -0,0 +1,42 @@ +/// Response model for LLM invocation. +class LlmResponse { + /// Creates an [LlmResponse]. + const LlmResponse({ + required this.result, + required this.model, + required this.latencyMs, + this.requestId, + }); + + /// Factory to create [LlmResponse] from JSON. + factory LlmResponse.fromJson(Map json) { + return LlmResponse( + result: json['result'] as Map, + model: json['model'] as String, + latencyMs: json['latencyMs'] as int, + requestId: json['requestId'] as String?, + ); + } + + /// The JSON result returned by the model. + final Map result; + + /// The model name used for invocation. + final String model; + + /// Time taken for the request in milliseconds. + final int latencyMs; + + /// The unique request ID from the server. + final String? requestId; + + /// Converts the response to a JSON map. + Map toJson() { + return { + 'result': result, + 'model': model, + 'latencyMs': latencyMs, + 'requestId': requestId, + }; + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_service.dart new file mode 100644 index 00000000..5bf6208d --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/llm/llm_service.dart @@ -0,0 +1,38 @@ +import 'package:krow_domain/krow_domain.dart'; +import '../core_api_endpoints.dart'; +import 'llm_response.dart'; + +/// Service for invoking Large Language Models (LLM). +class LlmService extends BaseCoreService { + /// Creates an [LlmService]. + LlmService(super.api); + + /// Invokes the LLM with a [prompt] and optional [schema]. + /// + /// [prompt] is the text instruction for the model. + /// [responseJsonSchema] is an optional JSON schema to enforce structure. + /// [fileUrls] are optional URLs of files (images/PDFs) to include in context. + Future invokeLlm({ + required String prompt, + Map? responseJsonSchema, + List? fileUrls, + }) async { + final ApiResponse res = await action(() async { + return api.post( + CoreApiEndpoints.invokeLlm, + data: { + 'prompt': prompt, + if (responseJsonSchema != null) + 'responseJsonSchema': responseJsonSchema, + if (fileUrls != null) 'fileUrls': fileUrls, + }, + ); + }); + + if (res.code.startsWith('2')) { + return LlmResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_response.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_response.dart new file mode 100644 index 00000000..bf286f07 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_response.dart @@ -0,0 +1,36 @@ +/// Response model for creating a signed URL. +class SignedUrlResponse { + /// Creates a [SignedUrlResponse]. + const SignedUrlResponse({ + required this.signedUrl, + required this.expiresAt, + this.requestId, + }); + + /// Factory to create [SignedUrlResponse] from JSON. + factory SignedUrlResponse.fromJson(Map json) { + return SignedUrlResponse( + signedUrl: json['signedUrl'] as String, + expiresAt: DateTime.parse(json['expiresAt'] as String), + requestId: json['requestId'] as String?, + ); + } + + /// The generated signed URL. + final String signedUrl; + + /// The timestamp when the URL expires. + final DateTime expiresAt; + + /// The unique request ID from the server. + final String? requestId; + + /// Converts the response to a JSON map. + Map toJson() { + return { + 'signedUrl': signedUrl, + 'expiresAt': expiresAt.toIso8601String(), + 'requestId': requestId, + }; + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_service.dart new file mode 100644 index 00000000..f25fea52 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/signed_url/signed_url_service.dart @@ -0,0 +1,34 @@ +import 'package:krow_domain/krow_domain.dart'; +import '../core_api_endpoints.dart'; +import 'signed_url_response.dart'; + +/// Service for creating signed URLs for Cloud Storage objects. +class SignedUrlService extends BaseCoreService { + /// Creates a [SignedUrlService]. + SignedUrlService(super.api); + + /// Creates a signed URL for a specific [fileUri]. + /// + /// [fileUri] should be in gs:// format. + /// [expiresInSeconds] must be <= 900. + Future createSignedUrl({ + required String fileUri, + int expiresInSeconds = 300, + }) async { + final ApiResponse res = await action(() async { + return api.post( + CoreApiEndpoints.createSignedUrl, + data: { + 'fileUri': fileUri, + 'expiresInSeconds': expiresInSeconds, + }, + ); + }); + + if (res.code.startsWith('2')) { + return SignedUrlResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_response.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_response.dart new file mode 100644 index 00000000..38f2ba25 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_response.dart @@ -0,0 +1,90 @@ +/// Represents the possible statuses of a verification job. +enum VerificationStatus { + /// Job is created and waiting to be processed. + pending('PENDING'), + + /// Job is currently being processed by machine or human. + processing('PROCESSING'), + + /// Machine verification passed automatically. + autoPass('AUTO_PASS'), + + /// Machine verification failed automatically. + autoFail('AUTO_FAIL'), + + /// Machine results are inconclusive and require human review. + needsReview('NEEDS_REVIEW'), + + /// Human reviewer approved the verification. + approved('APPROVED'), + + /// Human reviewer rejected the verification. + rejected('REJECTED'), + + /// An error occurred during processing. + error('ERROR'); + + const VerificationStatus(this.value); + + /// The string value expected by the Core API. + final String value; + + /// Creates a [VerificationStatus] from a string. + static VerificationStatus fromString(String value) { + return VerificationStatus.values.firstWhere( + (VerificationStatus e) => e.value == value, + orElse: () => VerificationStatus.error, + ); + } +} + +/// Response model for verification operations. +class VerificationResponse { + /// Creates a [VerificationResponse]. + const VerificationResponse({ + required this.verificationId, + required this.status, + this.type, + this.review, + this.requestId, + }); + + /// Factory to create [VerificationResponse] from JSON. + factory VerificationResponse.fromJson(Map json) { + return VerificationResponse( + verificationId: json['verificationId'] as String, + status: VerificationStatus.fromString(json['status'] as String), + type: json['type'] as String?, + review: json['review'] != null + ? json['review'] as Map + : null, + requestId: json['requestId'] as String?, + ); + } + + /// The unique ID of the verification job. + final String verificationId; + + /// Current status of the verification. + final VerificationStatus status; + + /// The type of verification (e.g., attire, government_id). + final String? type; + + /// Optional human review details. + final Map? review; + + /// The unique request ID from the server. + final String? requestId; + + /// Converts the response to a JSON map. + Map toJson() { + return { + 'verificationId': verificationId, + 'status': status.value, + 'type': type, + 'review': review, + 'requestId': requestId, + }; + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart new file mode 100644 index 00000000..73390819 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/core_api_services/verification/verification_service.dart @@ -0,0 +1,94 @@ +import 'package:krow_domain/krow_domain.dart'; +import '../core_api_endpoints.dart'; +import 'verification_response.dart'; + +/// Service for handling async verification jobs. +class VerificationService extends BaseCoreService { + /// Creates a [VerificationService]. + VerificationService(super.api); + + /// Enqueues a new verification job. + /// + /// [type] can be 'attire', 'government_id', etc. + /// [subjectType] is usually 'worker'. + /// [fileUri] is the gs:// path of the uploaded file. + Future createVerification({ + required String type, + required String subjectType, + required String subjectId, + required String fileUri, + Map? rules, + }) async { + final ApiResponse res = await action(() async { + return api.post( + CoreApiEndpoints.verifications, + data: { + 'type': type, + 'subjectType': subjectType, + 'subjectId': subjectId, + 'fileUri': fileUri, + if (rules != null) 'rules': rules, + }, + ); + }); + + if (res.code.startsWith('2')) { + return VerificationResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); + } + + /// Polls the status of a specific verification. + Future getStatus(String verificationId) async { + final ApiResponse res = await action(() async { + return api.get(CoreApiEndpoints.verificationStatus(verificationId)); + }); + + if (res.code.startsWith('2')) { + return VerificationResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); + } + + /// Submits a manual review decision. + /// + /// [decision] should be 'APPROVED' or 'REJECTED'. + Future reviewVerification({ + required String verificationId, + required String decision, + String? note, + String? reasonCode, + }) async { + final ApiResponse res = await action(() async { + return api.post( + CoreApiEndpoints.verificationReview(verificationId), + data: { + 'decision': decision, + if (note != null) 'note': note, + if (reasonCode != null) 'reasonCode': reasonCode, + }, + ); + }); + + if (res.code.startsWith('2')) { + return VerificationResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); + } + + /// Retries a verification job that failed or needs re-processing. + Future retryVerification(String verificationId) async { + final ApiResponse res = await action(() async { + return api.post(CoreApiEndpoints.verificationRetry(verificationId)); + }); + + if (res.code.startsWith('2')) { + return VerificationResponse.fromJson(res.data as Map); + } + + throw Exception(res.message); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart b/apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart new file mode 100644 index 00000000..e035ae18 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/dio_client.dart @@ -0,0 +1,27 @@ +import 'package:dio/dio.dart'; +import 'package:krow_core/src/services/api_service/inspectors/auth_interceptor.dart'; + +/// A custom Dio client for the Krow project that includes basic configuration +/// and an [AuthInterceptor]. +class DioClient extends DioMixin implements Dio { + DioClient([BaseOptions? baseOptions]) { + options = + baseOptions ?? + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 10), + ); + + // Use the default adapter + httpClientAdapter = HttpClientAdapter(); + + // Add interceptors + interceptors.addAll([ + AuthInterceptor(), + LogInterceptor( + requestBody: true, + responseBody: true, + ), // Added for better debugging + ]); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart b/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart new file mode 100644 index 00000000..d6974e57 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/api_service/inspectors/auth_interceptor.dart @@ -0,0 +1,24 @@ +import 'package:dio/dio.dart'; +import 'package:firebase_auth/firebase_auth.dart'; + +/// An interceptor that adds the Firebase Auth ID token to the Authorization header. +class AuthInterceptor extends Interceptor { + @override + Future onRequest( + RequestOptions options, + RequestInterceptorHandler handler, + ) async { + final User? user = FirebaseAuth.instance.currentUser; + if (user != null) { + try { + final String? token = await user.getIdToken(); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + } catch (e) { + rethrow; + } + } + return handler.next(options); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/device/camera/camera_service.dart b/apps/mobile/packages/core/lib/src/services/device/camera/camera_service.dart new file mode 100644 index 00000000..c7317aa4 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/camera/camera_service.dart @@ -0,0 +1,23 @@ +import 'package:image_picker/image_picker.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Service for capturing photos and videos using the device camera. +class CameraService extends BaseDeviceService { + /// Creates a [CameraService]. + CameraService(ImagePicker picker) : _picker = picker; + + final ImagePicker _picker; + + /// Captures a photo using the camera. + /// + /// Returns the path to the captured image, or null if cancelled. + Future takePhoto() async { + return action(() async { + final XFile? file = await _picker.pickImage( + source: ImageSource.camera, + imageQuality: 80, + ); + return file?.path; + }); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/device/file/file_picker_service.dart b/apps/mobile/packages/core/lib/src/services/device/file/file_picker_service.dart new file mode 100644 index 00000000..55321461 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/file/file_picker_service.dart @@ -0,0 +1,22 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Service for picking files from the device filesystem. +class FilePickerService extends BaseDeviceService { + /// Creates a [FilePickerService]. + const FilePickerService(); + + /// Picks a single file from the device. + /// + /// Returns the path to the selected file, or null if cancelled. + Future pickFile({List? allowedExtensions}) async { + return action(() async { + final FilePickerResult? result = await FilePicker.platform.pickFiles( + type: allowedExtensions != null ? FileType.custom : FileType.any, + allowedExtensions: allowedExtensions, + ); + + return result?.files.single.path; + }); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/device/file_upload/device_file_upload_service.dart b/apps/mobile/packages/core/lib/src/services/device/file_upload/device_file_upload_service.dart new file mode 100644 index 00000000..4fea7e77 --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/file_upload/device_file_upload_service.dart @@ -0,0 +1,60 @@ +import 'package:krow_domain/krow_domain.dart'; +import '../camera/camera_service.dart'; +import '../gallery/gallery_service.dart'; +import '../../api_service/core_api_services/file_upload/file_upload_service.dart'; +import '../../api_service/core_api_services/file_upload/file_upload_response.dart'; + +/// Orchestrator service that combines device picking and network uploading. +/// +/// This provides a simplified entry point for features to "pick and upload" +/// in a single call. +class DeviceFileUploadService extends BaseDeviceService { + /// Creates a [DeviceFileUploadService]. + DeviceFileUploadService({ + required this.cameraService, + required this.galleryService, + required this.apiUploadService, + }); + + final CameraService cameraService; + final GalleryService galleryService; + final FileUploadService apiUploadService; + + /// Captures a photo from the camera and uploads it immediately. + Future uploadFromCamera({ + required String fileName, + FileVisibility visibility = FileVisibility.private, + String? category, + }) async { + return action(() async { + final String? path = await cameraService.takePhoto(); + if (path == null) return null; + + return apiUploadService.uploadFile( + filePath: path, + fileName: fileName, + visibility: visibility, + category: category, + ); + }); + } + + /// Picks an image from the gallery and uploads it immediately. + Future uploadFromGallery({ + required String fileName, + FileVisibility visibility = FileVisibility.private, + String? category, + }) async { + return action(() async { + final String? path = await galleryService.pickImage(); + if (path == null) return null; + + return apiUploadService.uploadFile( + filePath: path, + fileName: fileName, + visibility: visibility, + category: category, + ); + }); + } +} diff --git a/apps/mobile/packages/core/lib/src/services/device/gallery/gallery_service.dart b/apps/mobile/packages/core/lib/src/services/device/gallery/gallery_service.dart new file mode 100644 index 00000000..7667e73d --- /dev/null +++ b/apps/mobile/packages/core/lib/src/services/device/gallery/gallery_service.dart @@ -0,0 +1,23 @@ +import 'package:image_picker/image_picker.dart'; +import 'package:krow_domain/krow_domain.dart'; + +/// Service for picking media from the device gallery. +class GalleryService extends BaseDeviceService { + /// Creates a [GalleryService]. + GalleryService(this._picker); + + final ImagePicker _picker; + + /// Picks an image from the gallery. + /// + /// Returns the path to the selected image, or null if cancelled. + Future pickImage() async { + return action(() async { + final XFile? file = await _picker.pickImage( + source: ImageSource.gallery, + imageQuality: 80, + ); + return file?.path; + }); + } +} diff --git a/apps/mobile/packages/core/pubspec.yaml b/apps/mobile/packages/core/pubspec.yaml index 80bacabe..08ec902f 100644 --- a/apps/mobile/packages/core/pubspec.yaml +++ b/apps/mobile/packages/core/pubspec.yaml @@ -11,10 +11,18 @@ environment: dependencies: flutter: sdk: flutter - flutter_bloc: ^8.1.0 - design_system: - path: ../design_system - equatable: ^2.0.8 - flutter_modular: ^6.4.1 + + # internal packages krow_domain: path: ../domain + design_system: + path: ../design_system + + flutter_bloc: ^8.1.0 + equatable: ^2.0.8 + flutter_modular: ^6.4.1 + dio: ^5.9.1 + image_picker: ^1.1.2 + path_provider: ^2.1.3 + file_picker: ^8.1.7 + firebase_auth: ^6.1.4 diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart index 9cdf0888..24f01a00 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/data/repositories/staff_connector_repository_impl.dart @@ -1,8 +1,7 @@ -// ignore_for_file: always_specify_types, depend_on_referenced_packages, dead_code, dead_null_aware_expression, unused_local_variable, unused_import, sort_constructors_first, prefer_final_fields, prefer_const_constructors, deprecated_member_use, implicit_call_tearoffs, implementation_imports -import 'package:firebase_data_connect/firebase_data_connect.dart'; -import 'package:krow_data_connect/krow_data_connect.dart' - hide AttireVerificationStatus; -import 'package:krow_domain/krow_domain.dart'; +import 'package:firebase_data_connect/firebase_data_connect.dart'; +import 'package:krow_data_connect/krow_data_connect.dart' as dc; +import 'package:krow_domain/krow_domain.dart' as domain; +import '../../domain/repositories/staff_connector_repository.dart'; /// Implementation of [StaffConnectorRepository]. /// @@ -12,10 +11,10 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { /// Creates a new [StaffConnectorRepositoryImpl]. /// /// Requires a [DataConnectService] instance for backend communication. - StaffConnectorRepositoryImpl({DataConnectService? service}) - : _service = service ?? DataConnectService.instance; + StaffConnectorRepositoryImpl({dc.DataConnectService? service}) + : _service = service ?? dc.DataConnectService.instance; - final DataConnectService _service; + final dc.DataConnectService _service; @override Future getProfileCompletion() async { @@ -23,17 +22,17 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { final String staffId = await _service.getStaffId(); final QueryResult< - GetStaffProfileCompletionData, - GetStaffProfileCompletionVariables + dc.GetStaffProfileCompletionData, + dc.GetStaffProfileCompletionVariables > response = await _service.connector .getStaffProfileCompletion(id: staffId) .execute(); - final GetStaffProfileCompletionStaff? staff = response.data.staff; - final List emergencyContacts = - response.data.emergencyContacts; - final List taxForms = + final dc.GetStaffProfileCompletionStaff? staff = response.data.staff; + final List + emergencyContacts = response.data.emergencyContacts; + final List taxForms = response.data.taxForms; return _isProfileComplete(staff, emergencyContacts, taxForms); @@ -46,15 +45,14 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { final String staffId = await _service.getStaffId(); final QueryResult< - GetStaffPersonalInfoCompletionData, - GetStaffPersonalInfoCompletionVariables + dc.GetStaffPersonalInfoCompletionData, + dc.GetStaffPersonalInfoCompletionVariables > response = await _service.connector .getStaffPersonalInfoCompletion(id: staffId) .execute(); - final GetStaffPersonalInfoCompletionStaff? staff = response.data.staff; - + final dc.GetStaffPersonalInfoCompletionStaff? staff = response.data.staff; return _isPersonalInfoComplete(staff); }); } @@ -65,8 +63,8 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { final String staffId = await _service.getStaffId(); final QueryResult< - GetStaffEmergencyProfileCompletionData, - GetStaffEmergencyProfileCompletionVariables + dc.GetStaffEmergencyProfileCompletionData, + dc.GetStaffEmergencyProfileCompletionVariables > response = await _service.connector .getStaffEmergencyProfileCompletion(id: staffId) @@ -82,16 +80,15 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { final String staffId = await _service.getStaffId(); final QueryResult< - GetStaffExperienceProfileCompletionData, - GetStaffExperienceProfileCompletionVariables + dc.GetStaffExperienceProfileCompletionData, + dc.GetStaffExperienceProfileCompletionVariables > response = await _service.connector .getStaffExperienceProfileCompletion(id: staffId) .execute(); - final GetStaffExperienceProfileCompletionStaff? staff = + final dc.GetStaffExperienceProfileCompletionStaff? staff = response.data.staff; - return _hasExperience(staff); }); } @@ -102,8 +99,8 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { final String staffId = await _service.getStaffId(); final QueryResult< - GetStaffTaxFormsProfileCompletionData, - GetStaffTaxFormsProfileCompletionVariables + dc.GetStaffTaxFormsProfileCompletionData, + dc.GetStaffTaxFormsProfileCompletionVariables > response = await _service.connector .getStaffTaxFormsProfileCompletion(id: staffId) @@ -114,148 +111,162 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { } /// Checks if personal info is complete. - bool _isPersonalInfoComplete(GetStaffPersonalInfoCompletionStaff? staff) { + bool _isPersonalInfoComplete(dc.GetStaffPersonalInfoCompletionStaff? staff) { if (staff == null) return false; final String fullName = staff.fullName; final String? email = staff.email; final String? phone = staff.phone; - return (fullName.trim().isNotEmpty ?? false) && + return fullName.trim().isNotEmpty && (email?.trim().isNotEmpty ?? false) && (phone?.trim().isNotEmpty ?? false); } /// Checks if staff has experience data (skills or industries). - bool _hasExperience(GetStaffExperienceProfileCompletionStaff? staff) { + bool _hasExperience(dc.GetStaffExperienceProfileCompletionStaff? staff) { if (staff == null) return false; - final dynamic skills = staff.skills; - final dynamic industries = staff.industries; - return (skills is List && skills.isNotEmpty) || - (industries is List && industries.isNotEmpty); + final List? skills = staff.skills; + final List? industries = staff.industries; + return (skills?.isNotEmpty ?? false) || (industries?.isNotEmpty ?? false); } /// Determines if the profile is complete based on all sections. bool _isProfileComplete( - GetStaffProfileCompletionStaff? staff, - List emergencyContacts, - List taxForms, + dc.GetStaffProfileCompletionStaff? staff, + List emergencyContacts, + List taxForms, ) { if (staff == null) return false; - final dynamic skills = staff.skills; - final dynamic industries = staff.industries; + + final List? skills = staff.skills; + final List? industries = staff.industries; final bool hasExperience = - (skills is List && skills.isNotEmpty) || - (industries is List && industries.isNotEmpty); - return emergencyContacts.isNotEmpty && taxForms.isNotEmpty && hasExperience; + (skills?.isNotEmpty ?? false) || (industries?.isNotEmpty ?? false); + + return (staff.fullName.trim().isNotEmpty) && + (staff.email?.trim().isNotEmpty ?? false) && + emergencyContacts.isNotEmpty && + taxForms.isNotEmpty && + hasExperience; } @override - Future getStaffProfile() async { + Future getStaffProfile() async { return _service.run(() async { final String staffId = await _service.getStaffId(); - final QueryResult response = - await _service.connector.getStaffById(id: staffId).execute(); + final QueryResult + response = await _service.connector.getStaffById(id: staffId).execute(); - if (response.data.staff == null) { - throw const ServerException(technicalMessage: 'Staff not found'); + final dc.GetStaffByIdStaff? staff = response.data.staff; + + if (staff == null) { + throw Exception('Staff not found'); } - final GetStaffByIdStaff rawStaff = response.data.staff!; - - // Map the raw data connect object to the Domain Entity - return Staff( - id: rawStaff.id, - authProviderId: rawStaff.userId, - name: rawStaff.fullName, - email: rawStaff.email ?? '', - phone: rawStaff.phone, - avatar: rawStaff.photoUrl, - status: StaffStatus.active, - address: rawStaff.addres, - totalShifts: rawStaff.totalShifts, - averageRating: rawStaff.averageRating, - onTimeRate: rawStaff.onTimeRate, - noShowCount: rawStaff.noShowCount, - cancellationCount: rawStaff.cancellationCount, - reliabilityScore: rawStaff.reliabilityScore, + return domain.Staff( + id: staff.id, + authProviderId: staff.userId, + name: staff.fullName, + email: staff.email ?? '', + phone: staff.phone, + avatar: staff.photoUrl, + status: domain.StaffStatus.active, + address: staff.addres, + totalShifts: staff.totalShifts, + averageRating: staff.averageRating, + onTimeRate: staff.onTimeRate, + noShowCount: staff.noShowCount, + cancellationCount: staff.cancellationCount, + reliabilityScore: staff.reliabilityScore, ); }); } @override - Future> getBenefits() async { + Future> getBenefits() async { return _service.run(() async { final String staffId = await _service.getStaffId(); final QueryResult< - ListBenefitsDataByStaffIdData, - ListBenefitsDataByStaffIdVariables + dc.ListBenefitsDataByStaffIdData, + dc.ListBenefitsDataByStaffIdVariables > response = await _service.connector .listBenefitsDataByStaffId(staffId: staffId) .execute(); - return response.data.benefitsDatas.map((data) { - final plan = data.vendorBenefitPlan; - return Benefit( - title: plan.title, - entitlementHours: plan.total?.toDouble() ?? 0.0, - usedHours: data.current.toDouble(), - ); - }).toList(); + return response.data.benefitsDatas + .map( + (dc.ListBenefitsDataByStaffIdBenefitsDatas e) => domain.Benefit( + title: e.vendorBenefitPlan.title, + entitlementHours: e.vendorBenefitPlan.total?.toDouble() ?? 0, + usedHours: e.current.toDouble(), + ), + ) + .toList(); }); } @override - Future> getAttireOptions() async { + Future> getAttireOptions() async { return _service.run(() async { final String staffId = await _service.getStaffId(); - // Fetch all options - final QueryResult optionsResponse = - await _service.connector.listAttireOptions().execute(); + final List> results = + await Future.wait>( + >>[ + _service.connector.listAttireOptions().execute(), + _service.connector.getStaffAttire(staffId: staffId).execute(), + ], + ); - // Fetch user's attire status - final QueryResult - attiresResponse = await _service.connector - .getStaffAttire(staffId: staffId) - .execute(); + final QueryResult optionsRes = + results[0] as QueryResult; + final QueryResult + staffAttireRes = + results[1] + as QueryResult; - final Map attireMap = { - for (final item in attiresResponse.data.staffAttires) - item.attireOptionId: item, - }; + final List staffAttire = + staffAttireRes.data.staffAttires; - return optionsResponse.data.attireOptions.map((e) { - final GetStaffAttireStaffAttires? userAttire = attireMap[e.id]; - return AttireItem( - id: e.itemId, - label: e.label, - description: e.description, - imageUrl: e.imageUrl, - isMandatory: e.isMandatory ?? false, - verificationStatus: _mapAttireStatus( - userAttire?.verificationStatus?.stringValue, - ), - photoUrl: userAttire?.verificationPhotoUrl, + return optionsRes.data.attireOptions.map(( + dc.ListAttireOptionsAttireOptions opt, + ) { + final dc.GetStaffAttireStaffAttires currentAttire = staffAttire + .firstWhere( + (dc.GetStaffAttireStaffAttires a) => a.attireOptionId == opt.id, + orElse: () => dc.GetStaffAttireStaffAttires( + attireOptionId: opt.id, + verificationPhotoUrl: null, + verificationId: null, + verificationStatus: null, + ), + ); + + return domain.AttireItem( + id: opt.id, + code: opt.itemId, + label: opt.label, + description: opt.description, + imageUrl: opt.imageUrl, + isMandatory: opt.isMandatory ?? false, + photoUrl: currentAttire.verificationPhotoUrl, + verificationId: currentAttire.verificationId, + verificationStatus: currentAttire.verificationStatus != null + ? _mapFromDCStatus(currentAttire.verificationStatus!) + : null, ); }).toList(); }); } - AttireVerificationStatus? _mapAttireStatus(String? status) { - if (status == null) return null; - return AttireVerificationStatus.values.firstWhere( - (e) => e.name.toUpperCase() == status.toUpperCase(), - orElse: () => AttireVerificationStatus.pending, - ); - } - @override Future upsertStaffAttire({ required String attireOptionId, required String photoUrl, String? verificationId, + domain.AttireVerificationStatus? verificationStatus, }) async { await _service.run(() async { final String staffId = await _service.getStaffId(); @@ -263,7 +274,68 @@ class StaffConnectorRepositoryImpl implements StaffConnectorRepository { await _service.connector .upsertStaffAttire(staffId: staffId, attireOptionId: attireOptionId) .verificationPhotoUrl(photoUrl) - // .verificationId(verificationId) // Uncomment after SDK regeneration + .verificationId(verificationId) + .verificationStatus( + verificationStatus != null + ? dc.AttireVerificationStatus.values.firstWhere( + (dc.AttireVerificationStatus e) => + e.name == verificationStatus.value.toUpperCase(), + orElse: () => dc.AttireVerificationStatus.PENDING, + ) + : null, + ) + .execute(); + }); + } + + domain.AttireVerificationStatus _mapFromDCStatus( + dc.EnumValue status, + ) { + if (status is dc.Unknown) { + return domain.AttireVerificationStatus.error; + } + final String name = + (status as dc.Known).value.name; + switch (name) { + case 'PENDING': + return domain.AttireVerificationStatus.pending; + case 'PROCESSING': + return domain.AttireVerificationStatus.processing; + case 'AUTO_PASS': + return domain.AttireVerificationStatus.autoPass; + case 'AUTO_FAIL': + return domain.AttireVerificationStatus.autoFail; + case 'NEEDS_REVIEW': + return domain.AttireVerificationStatus.needsReview; + case 'APPROVED': + return domain.AttireVerificationStatus.approved; + case 'REJECTED': + return domain.AttireVerificationStatus.rejected; + case 'ERROR': + return domain.AttireVerificationStatus.error; + default: + return domain.AttireVerificationStatus.error; + } + } + + @override + Future saveStaffProfile({ + String? firstName, + String? lastName, + String? bio, + String? profilePictureUrl, + }) async { + await _service.run(() async { + final String staffId = await _service.getStaffId(); + final String? fullName = (firstName != null || lastName != null) + ? '${firstName ?? ''} ${lastName ?? ''}'.trim() + : null; + + await _service.connector + .updateStaff(id: staffId) + .fullName(fullName) + .bio(bio) + .photoUrl(profilePictureUrl) .execute(); }); } diff --git a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart index e4cc2db8..3bd3c9e7 100644 --- a/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart +++ b/apps/mobile/packages/data_connect/lib/src/connectors/staff/domain/repositories/staff_connector_repository.dart @@ -55,6 +55,7 @@ abstract interface class StaffConnectorRepository { required String attireOptionId, required String photoUrl, String? verificationId, + AttireVerificationStatus? verificationStatus, }); /// Signs out the current user. @@ -63,4 +64,12 @@ abstract interface class StaffConnectorRepository { /// /// Throws an exception if the sign-out fails. Future signOut(); + + /// Saves the staff profile information. + Future saveStaffProfile({ + String? firstName, + String? lastName, + String? bio, + String? profilePictureUrl, + }); } diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index 562f5656..87167b9e 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -6,6 +6,15 @@ /// Note: Repository Interfaces are now located in their respective Feature packages. library; +// Core +export 'src/core/services/api_services/api_response.dart'; +export 'src/core/services/api_services/base_api_service.dart'; +export 'src/core/services/api_services/base_core_service.dart'; +export 'src/core/services/api_services/file_visibility.dart'; + +// Device +export 'src/core/services/device/base_device_service.dart'; + // Users & Membership export 'src/entities/users/user.dart'; export 'src/entities/users/staff.dart'; diff --git a/apps/mobile/packages/domain/lib/src/core/services/api_services/api_response.dart b/apps/mobile/packages/domain/lib/src/core/services/api_services/api_response.dart new file mode 100644 index 00000000..3e6a5435 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/services/api_services/api_response.dart @@ -0,0 +1,22 @@ +/// Represents a standardized response from the API. +class ApiResponse { + /// Creates an [ApiResponse]. + const ApiResponse({ + required this.code, + required this.message, + this.data, + this.errors = const {}, + }); + + /// The response code (e.g., '200', '404', or custom error code). + final String code; + + /// A descriptive message about the response. + final String message; + + /// The payload returned by the API. + final dynamic data; + + /// A map of field-specific error messages, if any. + final Map errors; +} diff --git a/apps/mobile/packages/domain/lib/src/core/services/api_services/base_api_service.dart b/apps/mobile/packages/domain/lib/src/core/services/api_services/base_api_service.dart new file mode 100644 index 00000000..ef9ccef6 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/services/api_services/base_api_service.dart @@ -0,0 +1,30 @@ +import 'api_response.dart'; + +/// Abstract base class for API services. +/// +/// This defines the contract for making HTTP requests. +abstract class BaseApiService { + /// Performs a GET request to the specified [endpoint]. + Future get(String endpoint, {Map? params}); + + /// Performs a POST request to the specified [endpoint]. + Future post( + String endpoint, { + dynamic data, + Map? params, + }); + + /// Performs a PUT request to the specified [endpoint]. + Future put( + String endpoint, { + dynamic data, + Map? params, + }); + + /// Performs a PATCH request to the specified [endpoint]. + Future patch( + String endpoint, { + dynamic data, + Map? params, + }); +} diff --git a/apps/mobile/packages/domain/lib/src/core/services/api_services/base_core_service.dart b/apps/mobile/packages/domain/lib/src/core/services/api_services/base_core_service.dart new file mode 100644 index 00000000..1acda2e3 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/services/api_services/base_core_service.dart @@ -0,0 +1,29 @@ +import 'api_response.dart'; +import 'base_api_service.dart'; + +/// Abstract base class for core business services. +/// +/// This provides a common [action] wrapper for standardized execution +/// and error catching across all core service implementations. +abstract class BaseCoreService { + /// Creates a [BaseCoreService] with the given [api] client. + const BaseCoreService(this.api); + + /// The API client used to perform requests. + final BaseApiService api; + + /// Standardized wrapper to execute API actions. + /// + /// This handles generic error normalization for unexpected non-HTTP errors. + Future action(Future Function() execution) async { + try { + return await execution(); + } catch (e) { + return ApiResponse( + code: 'CORE_INTERNAL_ERROR', + message: e.toString(), + errors: {'exception': e.runtimeType.toString()}, + ); + } + } +} diff --git a/apps/mobile/packages/domain/lib/src/core/services/api_services/file_visibility.dart b/apps/mobile/packages/domain/lib/src/core/services/api_services/file_visibility.dart new file mode 100644 index 00000000..2b0d7dd0 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/services/api_services/file_visibility.dart @@ -0,0 +1,14 @@ +/// Represents the accessibility level of an uploaded file. +enum FileVisibility { + /// File is accessible only to authenticated owners/authorized users. + private('private'), + + /// File is accessible publicly via its URL. + public('public'); + + /// Creates a [FileVisibility]. + const FileVisibility(this.value); + + /// The string value expected by the backend. + final String value; +} diff --git a/apps/mobile/packages/domain/lib/src/core/services/device/base_device_service.dart b/apps/mobile/packages/domain/lib/src/core/services/device/base_device_service.dart new file mode 100644 index 00000000..b8f030fc --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/core/services/device/base_device_service.dart @@ -0,0 +1,22 @@ +/// Abstract base class for device-related services. +/// +/// Device services handle native hardware/platform interactions +/// like Camera, Gallery, Location, or Biometrics. +abstract class BaseDeviceService { + const BaseDeviceService(); + + /// Standardized wrapper to execute device actions. + /// + /// This can be used for common handling like logging device interactions + /// or catching native platform exceptions. + Future action(Future Function() execution) async { + try { + return await execution(); + } catch (e) { + // Re-throw or handle based on project preference. + // For device services, we might want to throw specific + // DeviceExceptions later. + rethrow; + } + } +} diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart b/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart index d830add4..d794ca9e 100644 --- a/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart +++ b/apps/mobile/packages/domain/lib/src/entities/profile/attire_item.dart @@ -9,6 +9,7 @@ class AttireItem extends Equatable { /// Creates an [AttireItem]. const AttireItem({ required this.id, + required this.code, required this.label, this.description, this.imageUrl, @@ -18,9 +19,12 @@ class AttireItem extends Equatable { this.verificationId, }); - /// Unique identifier of the attire item. + /// Unique identifier of the attire item (UUID). final String id; + /// String code for the attire item (e.g. BLACK_TSHIRT). + final String code; + /// Display name of the item. final String label; @@ -45,6 +49,7 @@ class AttireItem extends Equatable { @override List get props => [ id, + code, label, description, imageUrl, @@ -53,4 +58,29 @@ class AttireItem extends Equatable { photoUrl, verificationId, ]; + + /// Creates a copy of this [AttireItem] with the given fields replaced. + AttireItem copyWith({ + String? id, + String? code, + String? label, + String? description, + String? imageUrl, + bool? isMandatory, + AttireVerificationStatus? verificationStatus, + String? photoUrl, + String? verificationId, + }) { + return AttireItem( + id: id ?? this.id, + code: code ?? this.code, + label: label ?? this.label, + description: description ?? this.description, + imageUrl: imageUrl ?? this.imageUrl, + isMandatory: isMandatory ?? this.isMandatory, + verificationStatus: verificationStatus ?? this.verificationStatus, + photoUrl: photoUrl ?? this.photoUrl, + verificationId: verificationId ?? this.verificationId, + ); + } } diff --git a/apps/mobile/packages/domain/lib/src/entities/profile/attire_verification_status.dart b/apps/mobile/packages/domain/lib/src/entities/profile/attire_verification_status.dart index bc5a3430..f766e8dc 100644 --- a/apps/mobile/packages/domain/lib/src/entities/profile/attire_verification_status.dart +++ b/apps/mobile/packages/domain/lib/src/entities/profile/attire_verification_status.dart @@ -1,11 +1,39 @@ /// Represents the verification status of an attire item photo. enum AttireVerificationStatus { - /// The photo is waiting for review. - pending, + /// Job is created and waiting to be processed. + pending('PENDING'), - /// The photo was rejected. - failed, + /// Job is currently being processed by machine or human. + processing('PROCESSING'), - /// The photo was approved. - success, + /// Machine verification passed automatically. + autoPass('AUTO_PASS'), + + /// Machine verification failed automatically. + autoFail('AUTO_FAIL'), + + /// Machine results are inconclusive and require human review. + needsReview('NEEDS_REVIEW'), + + /// Human reviewer approved the verification. + approved('APPROVED'), + + /// Human reviewer rejected the verification. + rejected('REJECTED'), + + /// An error occurred during processing. + error('ERROR'); + + const AttireVerificationStatus(this.value); + + /// The string value expected by the Core API. + final String value; + + /// Creates a [AttireVerificationStatus] from a string. + static AttireVerificationStatus fromString(String value) { + return AttireVerificationStatus.values.firstWhere( + (AttireVerificationStatus e) => e.value == value, + orElse: () => AttireVerificationStatus.error, + ); + } } diff --git a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/savings_card.dart b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/savings_card.dart index cc455c67..271fda78 100644 --- a/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/savings_card.dart +++ b/apps/mobile/packages/features/client/billing/lib/src/presentation/widgets/savings_card.dart @@ -46,7 +46,7 @@ class SavingsCard extends StatelessWidget { const SizedBox(height: UiConstants.space1), Text( // Using a hardcoded 180 here to match prototype mock or derived value - t.client_billing.rate_optimization_body(amount: 180), + "180", style: UiTypography.footnote2r.textSecondary, ), const SizedBox(height: UiConstants.space2), diff --git a/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugin_registrant.cc b/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugin_registrant.cc index e71a16d2..64a0ecea 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); + file_selector_plugin_register_with_registrar(file_selector_linux_registrar); } diff --git a/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugins.cmake b/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugins.cmake index 2e1de87a..2db3c22a 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugins.cmake +++ b/apps/mobile/packages/features/client/orders/orders_common/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift b/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift index 8bd29968..8a0af98d 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/apps/mobile/packages/features/client/orders/orders_common/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,12 +5,16 @@ import FlutterMacOS import Foundation +import file_picker +import file_selector_macos import firebase_app_check import firebase_auth import firebase_core import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) + FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc index d141b74f..5861e0f0 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugin_registrant.cc @@ -6,10 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { + FileSelectorWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FileSelectorWindows")); FirebaseAuthPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); FirebaseCorePluginCApiRegisterWithRegistrar( diff --git a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake index 29944d5b..ce851e9d 100644 --- a/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake +++ b/apps/mobile/packages/features/client/orders/orders_common/windows/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + file_selector_windows firebase_auth firebase_core ) diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart index eb32cf88..f574b6d1 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/attire_module.dart @@ -1,5 +1,7 @@ import 'package:flutter_modular/flutter_modular.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; import 'package:staff_attire/src/presentation/blocs/attire/attire_cubit.dart'; import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_cubit.dart'; @@ -8,11 +10,20 @@ import 'domain/repositories/attire_repository.dart'; import 'domain/usecases/get_attire_options_usecase.dart'; import 'domain/usecases/save_attire_usecase.dart'; import 'domain/usecases/upload_attire_photo_usecase.dart'; +import 'presentation/pages/attire_capture_page.dart'; import 'presentation/pages/attire_page.dart'; class StaffAttireModule extends Module { @override void binds(Injector i) { + /// third party services + i.addLazySingleton(ImagePicker.new); + + /// local services + i.addLazySingleton( + () => CameraService(i.get()), + ); + // Repository i.addLazySingleton(AttireRepositoryImpl.new); @@ -22,7 +33,7 @@ class StaffAttireModule extends Module { i.addLazySingleton(UploadAttirePhotoUseCase.new); // BLoC - i.addLazySingleton(AttireCubit.new); + i.add(AttireCubit.new); i.add(AttireCaptureCubit.new); } @@ -32,5 +43,12 @@ class StaffAttireModule extends Module { StaffPaths.childRoute(StaffPaths.attire, StaffPaths.attire), child: (_) => const AttirePage(), ); + r.child( + StaffPaths.childRoute(StaffPaths.attire, StaffPaths.attireCapture), + child: (_) => AttireCapturePage( + item: r.args.data['item'] as AttireItem, + initialPhotoUrl: r.args.data['initialPhotoUrl'] as String?, + ), + ); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart index 727c8f77..65645ad8 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/data/repositories_impl/attire_repository_impl.dart @@ -1,4 +1,8 @@ -import 'package:krow_data_connect/krow_data_connect.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_core/core.dart'; +import 'package:krow_data_connect/krow_data_connect.dart' + hide AttireVerificationStatus; import 'package:krow_domain/krow_domain.dart'; import '../../domain/repositories/attire_repository.dart'; @@ -31,16 +35,97 @@ class AttireRepositoryImpl implements AttireRepository { } @override - Future uploadPhoto(String itemId) async { - // In a real app, this would upload to Firebase Storage first. - // Since the prototype returns a mock URL, we'll use that to upsert our record. - final String mockUrl = 'mock_url_for_$itemId'; - - await _connector.upsertStaffAttire( - attireOptionId: itemId, - photoUrl: mockUrl, + Future uploadPhoto(String itemId, String filePath) async { + // 1. Upload file to Core API + final FileUploadService uploadService = Modular.get(); + final FileUploadResponse uploadRes = await uploadService.uploadFile( + filePath: filePath, + fileName: filePath.split('/').last, ); - return mockUrl; + final String fileUri = uploadRes.fileUri; + + // 2. Create signed URL for the uploaded file + final SignedUrlService signedUrlService = Modular.get(); + final SignedUrlResponse signedUrlRes = await signedUrlService + .createSignedUrl(fileUri: fileUri); + final String photoUrl = signedUrlRes.signedUrl; + + // 3. Initiate verification job + final VerificationService verificationService = + Modular.get(); + final Staff staff = await _connector.getStaffProfile(); + + // Get item details for verification rules + final List options = await _connector.getAttireOptions(); + final AttireItem targetItem = options.firstWhere( + (AttireItem e) => e.id == itemId, + ); + final String dressCode = + '${targetItem.description ?? ''} ${targetItem.label}'.trim(); + + final VerificationResponse verifyRes = await verificationService + .createVerification( + type: 'attire', + subjectType: 'worker', + subjectId: staff.id, + fileUri: fileUri, + rules: {'dressCode': dressCode}, + ); + final String verificationId = verifyRes.verificationId; + VerificationStatus currentStatus = verifyRes.status; + + // 4. Poll for status until it's finished or timeout (max 10 seconds) + try { + int attempts = 0; + bool isFinished = false; + while (!isFinished && attempts < 5) { + await Future.delayed(const Duration(seconds: 2)); + final VerificationResponse statusRes = await verificationService + .getStatus(verificationId); + currentStatus = statusRes.status; + if (currentStatus != VerificationStatus.pending && + currentStatus != VerificationStatus.processing) { + isFinished = true; + } + attempts++; + } + } catch (e) { + debugPrint('Polling failed or timed out: $e'); + // Continue anyway, as we have the verificationId + } + + // 5. Update Data Connect + await _connector.upsertStaffAttire( + attireOptionId: itemId, + photoUrl: photoUrl, + verificationId: verificationId, + verificationStatus: _mapToAttireStatus(currentStatus), + ); + + // 6. Return updated AttireItem by re-fetching to get the PENDING/SUCCESS status + final List finalOptions = await _connector.getAttireOptions(); + return finalOptions.firstWhere((AttireItem e) => e.id == itemId); + } + + AttireVerificationStatus _mapToAttireStatus(VerificationStatus status) { + switch (status) { + case VerificationStatus.pending: + return AttireVerificationStatus.pending; + case VerificationStatus.processing: + return AttireVerificationStatus.processing; + case VerificationStatus.autoPass: + return AttireVerificationStatus.autoPass; + case VerificationStatus.autoFail: + return AttireVerificationStatus.autoFail; + case VerificationStatus.needsReview: + return AttireVerificationStatus.needsReview; + case VerificationStatus.approved: + return AttireVerificationStatus.approved; + case VerificationStatus.rejected: + return AttireVerificationStatus.rejected; + case VerificationStatus.error: + return AttireVerificationStatus.error; + } } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/arguments/upload_attire_photo_arguments.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/arguments/upload_attire_photo_arguments.dart index 1745879c..dafdac1f 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/arguments/upload_attire_photo_arguments.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/arguments/upload_attire_photo_arguments.dart @@ -7,10 +7,17 @@ class UploadAttirePhotoArguments extends UseCaseArgument { // We'll stick to that signature for now to "preserve behavior". /// Creates a [UploadAttirePhotoArguments]. - const UploadAttirePhotoArguments({required this.itemId}); + const UploadAttirePhotoArguments({ + required this.itemId, + required this.filePath, + }); + /// The ID of the attire item being uploaded. final String itemId; + /// The local path to the photo file. + final String filePath; + @override - List get props => [itemId]; + List get props => [itemId, filePath]; } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart index 1b4742ad..a57107c0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/repositories/attire_repository.dart @@ -4,8 +4,8 @@ abstract interface class AttireRepository { /// Fetches the list of available attire options. Future> getAttireOptions(); - /// Simulates uploading a photo for a specific attire item. - Future uploadPhoto(String itemId); + /// Uploads a photo for a specific attire item. + Future uploadPhoto(String itemId, String filePath); /// Saves the user's attire selection and attestations. Future saveAttire({ diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart index 7c6de30a..39cd456b 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/domain/usecases/upload_attire_photo_usecase.dart @@ -1,16 +1,17 @@ import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; import '../arguments/upload_attire_photo_arguments.dart'; import '../repositories/attire_repository.dart'; /// Use case to upload a photo for an attire item. -class UploadAttirePhotoUseCase extends UseCase { - +class UploadAttirePhotoUseCase + extends UseCase { /// Creates a [UploadAttirePhotoUseCase]. UploadAttirePhotoUseCase(this._repository); final AttireRepository _repository; @override - Future call(UploadAttirePhotoArguments arguments) { - return _repository.uploadPhoto(arguments.itemId); + Future call(UploadAttirePhotoArguments arguments) { + return _repository.uploadPhoto(arguments.itemId, arguments.filePath); } } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_cubit.dart index ce9862d5..bc643b5a 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_cubit.dart @@ -64,9 +64,25 @@ class AttireCubit extends Cubit emit(state.copyWith(selectedIds: currentSelection)); } - void syncCapturedPhoto(String itemId, String url) { - // When a photo is captured, we refresh the options to get the updated status from backend - loadOptions(); + void updateFilter(String filter) { + emit(state.copyWith(filter: filter)); + } + + void syncCapturedPhoto(AttireItem item) { + // Update the options list with the new item data + final List updatedOptions = state.options + .map((AttireItem e) => e.id == item.id ? item : e) + .toList(); + + // Update the photo URLs map + final Map updatedPhotos = Map.from( + state.photoUrls, + ); + if (item.photoUrl != null) { + updatedPhotos[item.id] = item.photoUrl!; + } + + emit(state.copyWith(options: updatedOptions, photoUrls: updatedPhotos)); } Future save() async { diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_state.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_state.dart index 3d882c07..e137aff2 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire/attire_state.dart @@ -9,12 +9,14 @@ class AttireState extends Equatable { this.options = const [], this.selectedIds = const [], this.photoUrls = const {}, + this.filter = 'All', this.errorMessage, }); final AttireStatus status; final List options; final List selectedIds; final Map photoUrls; + final String filter; final String? errorMessage; /// Helper to check if item is mandatory @@ -22,7 +24,7 @@ class AttireState extends Equatable { return options .firstWhere( (AttireItem e) => e.id == id, - orElse: () => const AttireItem(id: '', label: ''), + orElse: () => const AttireItem(id: '', code: '', label: ''), ) .isMandatory; } @@ -44,11 +46,20 @@ class AttireState extends Equatable { bool get canSave => allMandatorySelected && allMandatoryHavePhotos; + List get filteredOptions { + return options.where((AttireItem item) { + if (filter == 'Required') return item.isMandatory; + if (filter == 'Non-Essential') return !item.isMandatory; + return true; + }).toList(); + } + AttireState copyWith({ AttireStatus? status, List? options, List? selectedIds, Map? photoUrls, + String? filter, String? errorMessage, }) { return AttireState( @@ -56,6 +67,7 @@ class AttireState extends Equatable { options: options ?? this.options, selectedIds: selectedIds ?? this.selectedIds, photoUrls: photoUrls ?? this.photoUrls, + filter: filter ?? this.filter, errorMessage: errorMessage, ); } @@ -66,6 +78,7 @@ class AttireState extends Equatable { options, selectedIds, photoUrls, + filter, errorMessage, ]; } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_cubit.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_cubit.dart index 884abb37..a3b9eca1 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_cubit.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_cubit.dart @@ -1,5 +1,6 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:krow_core/core.dart'; +import 'package:krow_domain/krow_domain.dart'; import 'package:staff_attire/src/domain/arguments/upload_attire_photo_arguments.dart'; import 'package:staff_attire/src/domain/usecases/upload_attire_photo_usecase.dart'; @@ -16,18 +17,22 @@ class AttireCaptureCubit extends Cubit emit(state.copyWith(isAttested: value)); } - Future uploadPhoto(String itemId) async { + Future uploadPhoto(String itemId, String filePath) async { emit(state.copyWith(status: AttireCaptureStatus.uploading)); await handleError( emit: emit, action: () async { - final String url = await _uploadAttirePhotoUseCase( - UploadAttirePhotoArguments(itemId: itemId), + final AttireItem item = await _uploadAttirePhotoUseCase( + UploadAttirePhotoArguments(itemId: itemId, filePath: filePath), ); emit( - state.copyWith(status: AttireCaptureStatus.success, photoUrl: url), + state.copyWith( + status: AttireCaptureStatus.success, + photoUrl: item.photoUrl, + updatedItem: item, + ), ); }, onError: (String errorKey) => state.copyWith( diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_state.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_state.dart index 6b776816..79f6e28a 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_state.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/blocs/attire_capture/attire_capture_state.dart @@ -1,4 +1,5 @@ import 'package:equatable/equatable.dart'; +import 'package:krow_domain/krow_domain.dart'; enum AttireCaptureStatus { initial, uploading, success, failure } @@ -7,24 +8,28 @@ class AttireCaptureState extends Equatable { this.status = AttireCaptureStatus.initial, this.isAttested = false, this.photoUrl, + this.updatedItem, this.errorMessage, }); final AttireCaptureStatus status; final bool isAttested; final String? photoUrl; + final AttireItem? updatedItem; final String? errorMessage; AttireCaptureState copyWith({ AttireCaptureStatus? status, bool? isAttested, String? photoUrl, + AttireItem? updatedItem, String? errorMessage, }) { return AttireCaptureState( status: status ?? this.status, isAttested: isAttested ?? this.isAttested, photoUrl: photoUrl ?? this.photoUrl, + updatedItem: updatedItem ?? this.updatedItem, errorMessage: errorMessage, ); } @@ -34,6 +39,7 @@ class AttireCaptureState extends Equatable { status, isAttested, photoUrl, + updatedItem, errorMessage, ]; } diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart index 5585f500..82109743 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart @@ -3,23 +3,28 @@ 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:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_cubit.dart'; import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_state.dart'; -import '../widgets/attestation_checkbox.dart'; -import '../widgets/attire_capture_page/attire_image_preview.dart'; -import '../widgets/attire_capture_page/attire_upload_buttons.dart'; -import '../widgets/attire_capture_page/attire_verification_status_card.dart'; +import '../widgets/attire_capture_page/footer_section.dart'; +import '../widgets/attire_capture_page/image_preview_section.dart'; +import '../widgets/attire_capture_page/info_section.dart'; +/// The [AttireCapturePage] allows users to capture or upload a photo of a specific attire item. class AttireCapturePage extends StatefulWidget { + /// Creates an [AttireCapturePage]. const AttireCapturePage({ super.key, required this.item, this.initialPhotoUrl, }); + /// The attire item being captured. final AttireItem item; + + /// Optional initial photo URL if it was already uploaded. final String? initialPhotoUrl; @override @@ -27,21 +32,148 @@ class AttireCapturePage extends StatefulWidget { } class _AttireCapturePageState extends State { - void _onUpload(BuildContext context) { + String? _selectedLocalPath; + + /// Whether a verification status is already present for this item. + bool get _hasVerificationStatus => widget.item.verificationStatus != null; + + /// Whether the item is currently pending verification. + bool get _isPending => + widget.item.verificationStatus == AttireVerificationStatus.pending; + + /// On gallery button press + Future _onGallery(BuildContext context) async { final AttireCaptureCubit cubit = BlocProvider.of( context, ); - if (!cubit.state.isAttested) { - UiSnackbar.show( - context, - message: 'Please attest that you own this item.', - type: UiSnackbarType.error, - margin: const EdgeInsets.all(UiConstants.space4), - ); + + // Skip attestation check if we already have a verification status + if (!_hasVerificationStatus && !cubit.state.isAttested) { + _showAttestationWarning(context); return; } - // Call the upload via cubit - cubit.uploadPhoto(widget.item.id); + + try { + final GalleryService service = Modular.get(); + final String? path = await service.pickImage(); + if (path != null && context.mounted) { + setState(() { + _selectedLocalPath = path; + }); + } + } catch (e) { + if (context.mounted) { + _showError(context, 'Could not access gallery: $e'); + } + } + } + + /// On camera button press + Future _onCamera(BuildContext context) async { + final AttireCaptureCubit cubit = BlocProvider.of( + context, + ); + + // Skip attestation check if we already have a verification status + if (!_hasVerificationStatus && !cubit.state.isAttested) { + _showAttestationWarning(context); + return; + } + + try { + final CameraService service = Modular.get(); + final String? path = await service.takePhoto(); + if (path != null && context.mounted) { + setState(() { + _selectedLocalPath = path; + }); + } + } catch (e) { + if (context.mounted) { + _showError(context, 'Could not access camera: $e'); + } + } + } + + /// Show a bottom sheet for reuploading options. + void _onReupload(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (BuildContext sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.photo_library), + title: const Text('Gallery'), + onTap: () { + Modular.to.pop(); + _onGallery(context); + }, + ), + ListTile( + leading: const Icon(Icons.camera_alt), + title: const Text('Camera'), + onTap: () { + Modular.to.pop(); + _onCamera(context); + }, + ), + ], + ), + ), + ); + } + + void _showAttestationWarning(BuildContext context) { + UiSnackbar.show( + context, + message: 'Please attest that you own this item.', + type: UiSnackbarType.error, + margin: const EdgeInsets.all(UiConstants.space4), + ); + } + + void _showError(BuildContext context, String message) { + debugPrint(message); + UiSnackbar.show( + context, + message: 'Could not access camera or gallery. Please try again.', + type: UiSnackbarType.error, + margin: const EdgeInsets.all(UiConstants.space4), + ); + } + + Future _onSubmit(BuildContext context) async { + final AttireCaptureCubit cubit = BlocProvider.of( + context, + ); + if (_selectedLocalPath == null) return; + + await cubit.uploadPhoto(widget.item.id, _selectedLocalPath!); + if (context.mounted && cubit.state.status == AttireCaptureStatus.success) { + setState(() { + _selectedLocalPath = null; + }); + } + } + + String _getStatusText(bool hasUploadedPhoto) { + return switch (widget.item.verificationStatus) { + AttireVerificationStatus.approved => 'Approved', + AttireVerificationStatus.rejected => 'Rejected', + AttireVerificationStatus.pending => 'Pending Verification', + _ => hasUploadedPhoto ? 'Pending Verification' : 'Not Uploaded', + }; + } + + Color _getStatusColor(bool hasUploadedPhoto) { + return switch (widget.item.verificationStatus) { + AttireVerificationStatus.approved => UiColors.textSuccess, + AttireVerificationStatus.rejected => UiColors.textError, + AttireVerificationStatus.pending => UiColors.textWarning, + _ => hasUploadedPhoto ? UiColors.textWarning : UiColors.textInactive, + }; } @override @@ -55,7 +187,12 @@ class _AttireCapturePageState extends State { ); return Scaffold( - appBar: UiAppBar(title: widget.item.label, showBackButton: true), + appBar: UiAppBar( + title: widget.item.label, + onLeadingPressed: () { + Modular.to.toAttire(); + }, + ), body: BlocConsumer( bloc: cubit, listener: (BuildContext context, AttireCaptureState state) { @@ -66,35 +203,21 @@ class _AttireCapturePageState extends State { type: UiSnackbarType.error, ); } + + if (state.status == AttireCaptureStatus.success) { + UiSnackbar.show( + context, + message: 'Attire image submitted for verification', + type: UiSnackbarType.success, + ); + Modular.to.toAttire(); + } }, builder: (BuildContext context, AttireCaptureState state) { - final bool isUploading = - state.status == AttireCaptureStatus.uploading; final String? currentPhotoUrl = state.photoUrl ?? widget.initialPhotoUrl; final bool hasUploadedPhoto = currentPhotoUrl != null; - final String statusText = switch (widget - .item - .verificationStatus) { - AttireVerificationStatus.success => 'Approved', - AttireVerificationStatus.failed => 'Rejected', - AttireVerificationStatus.pending => 'Pending Verification', - _ => - hasUploadedPhoto ? 'Pending Verification' : 'Not Uploaded', - }; - - final Color statusColor = - switch (widget.item.verificationStatus) { - AttireVerificationStatus.success => UiColors.textSuccess, - AttireVerificationStatus.failed => UiColors.textError, - AttireVerificationStatus.pending => UiColors.textWarning, - _ => - hasUploadedPhoto - ? UiColors.textWarning - : UiColors.textInactive, - }; - return Column( children: [ Expanded( @@ -102,98 +225,39 @@ class _AttireCapturePageState extends State { padding: const EdgeInsets.all(UiConstants.space5), child: Column( children: [ - // Image Preview (Toggle between example and uploaded) - if (hasUploadedPhoto) ...[ - Text( - 'Your Uploaded Photo', - style: UiTypography.body1b.textPrimary, - ), - const SizedBox(height: UiConstants.space2), - AttireImagePreview(imageUrl: currentPhotoUrl), - const SizedBox(height: UiConstants.space4), - Text( - 'Reference Example', - style: UiTypography.body2b.textSecondary, - ), - const SizedBox(height: UiConstants.space1), - Center( - child: ClipRRect( - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, - ), - child: Image.network( - widget.item.imageUrl ?? '', - height: 120, - fit: BoxFit.cover, - errorBuilder: (_, __, ___) => - const SizedBox.shrink(), - ), - ), - ), - ] else ...[ - AttireImagePreview( - imageUrl: widget.item.imageUrl, - ), - const SizedBox(height: UiConstants.space4), - Text( - 'Example of the item that you need to upload.', - style: UiTypography.body1b.textSecondary, - textAlign: TextAlign.center, - ), - ], - - const SizedBox(height: UiConstants.space6), - if (widget.item.description != null) - Text( - widget.item.description!, - style: UiTypography.body1r.textSecondary, - textAlign: TextAlign.center, - ), - const SizedBox(height: UiConstants.space8), - - // Verification info - AttireVerificationStatusCard( - statusText: statusText, - statusColor: statusColor, + ImagePreviewSection( + selectedLocalPath: _selectedLocalPath, + currentPhotoUrl: currentPhotoUrl, + referenceImageUrl: widget.item.imageUrl, ), - const SizedBox(height: UiConstants.space6), - - AttestationCheckbox( - isChecked: state.isAttested, - onChanged: (bool? val) { + const SizedBox(height: UiConstants.space1), + InfoSection( + description: widget.item.description, + statusText: _getStatusText(hasUploadedPhoto), + statusColor: _getStatusColor(hasUploadedPhoto), + isPending: _isPending, + showCheckbox: !_hasVerificationStatus, + isAttested: state.isAttested, + onAttestationChanged: (bool? val) { cubit.toggleAttestation(val ?? false); }, ), - const SizedBox(height: UiConstants.space6), - - if (isUploading) - const Center( - child: Padding( - padding: EdgeInsets.all(UiConstants.space8), - child: CircularProgressIndicator(), - ), - ) - else - AttireUploadButtons(onUpload: _onUpload), ], ), ), ), - if (hasUploadedPhoto) - SafeArea( - child: Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: SizedBox( - width: double.infinity, - child: UiButton.primary( - text: 'Submit Image', - onPressed: () { - Modular.to.pop(currentPhotoUrl); - }, - ), - ), - ), - ), + FooterSection( + isUploading: + state.status == AttireCaptureStatus.uploading, + selectedLocalPath: _selectedLocalPath, + hasVerificationStatus: _hasVerificationStatus, + hasUploadedPhoto: hasUploadedPhoto, + updatedItem: state.updatedItem, + onGallery: () => _onGallery(context), + onCamera: () => _onCamera(context), + onSubmit: () => _onSubmit(context), + onReupload: () => _onReupload(context), + ), ], ); }, diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart index c2782981..280fd344 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_page.dart @@ -3,6 +3,7 @@ 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:krow_core/core.dart'; import 'package:krow_domain/krow_domain.dart'; import 'package:staff_attire/src/presentation/blocs/attire/attire_cubit.dart'; import 'package:staff_attire/src/presentation/blocs/attire/attire_state.dart'; @@ -10,18 +11,10 @@ import 'package:staff_attire/src/presentation/blocs/attire/attire_state.dart'; import '../widgets/attire_filter_chips.dart'; import '../widgets/attire_info_card.dart'; import '../widgets/attire_item_card.dart'; -import 'attire_capture_page.dart'; -class AttirePage extends StatefulWidget { +class AttirePage extends StatelessWidget { const AttirePage({super.key}); - @override - State createState() => _AttirePageState(); -} - -class _AttirePageState extends State { - String _filter = 'All'; - @override Widget build(BuildContext context) { final AttireCubit cubit = Modular.get(); @@ -30,6 +23,7 @@ class _AttirePageState extends State { appBar: UiAppBar( title: t.staff_profile_attire.title, showBackButton: true, + onLeadingPressed: () => Modular.to.toProfile(), ), body: BlocProvider.value( value: cubit, @@ -48,14 +42,7 @@ class _AttirePageState extends State { return const Center(child: CircularProgressIndicator()); } - final List options = state.options; - final List filteredOptions = options.where(( - AttireItem item, - ) { - if (_filter == 'Required') return item.isMandatory; - if (_filter == 'Non-Essential') return !item.isMandatory; - return true; - }).toList(); + final List filteredOptions = state.filteredOptions; return Column( children: [ @@ -70,12 +57,8 @@ class _AttirePageState extends State { // Filter Chips AttireFilterChips( - selectedFilter: _filter, - onFilterChanged: (String value) { - setState(() { - _filter = value; - }); - }, + selectedFilter: state.filter, + onFilterChanged: cubit.updateFilter, ), const SizedBox(height: UiConstants.space6), @@ -112,23 +95,11 @@ class _AttirePageState extends State { item: item, isUploading: false, uploadedPhotoUrl: state.photoUrls[item.id], - onTap: () async { - final String? resultUrl = - await Navigator.push( - context, - MaterialPageRoute( - builder: (BuildContext ctx) => - AttireCapturePage( - item: item, - initialPhotoUrl: - state.photoUrls[item.id], - ), - ), - ); - - if (resultUrl != null && mounted) { - cubit.syncCapturedPhoto(item.id, resultUrl); - } + onTap: () { + Modular.to.toAttireCapture( + item: item, + initialPhotoUrl: state.photoUrls[item.id], + ); }, ), ); diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_image_preview.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_image_preview.dart index 5adfeec2..0e670951 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_image_preview.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_image_preview.dart @@ -1,10 +1,23 @@ +import 'dart:io'; + import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; class AttireImagePreview extends StatelessWidget { - const AttireImagePreview({super.key, required this.imageUrl}); + const AttireImagePreview({super.key, this.imageUrl, this.localPath}); final String? imageUrl; + final String? localPath; + + ImageProvider get _imageProvider { + if (localPath != null) { + return FileImage(File(localPath!)); + } + return NetworkImage( + imageUrl ?? + 'https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400', + ); + } void _viewEnlargedImage(BuildContext context) { showDialog( @@ -17,10 +30,7 @@ class AttireImagePreview extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(UiConstants.radiusBase), image: DecorationImage( - image: NetworkImage( - imageUrl ?? - 'https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400', - ), + image: _imageProvider, fit: BoxFit.contain, ), ), @@ -47,13 +57,7 @@ class AttireImagePreview extends StatelessWidget { offset: Offset(0, 2), ), ], - image: DecorationImage( - image: NetworkImage( - imageUrl ?? - 'https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400', - ), - fit: BoxFit.cover, - ), + image: DecorationImage(image: _imageProvider, fit: BoxFit.cover), ), child: const Align( alignment: Alignment.bottomRight, diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_upload_buttons.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_upload_buttons.dart index 83067e7e..e6bcb712 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_upload_buttons.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/attire_upload_buttons.dart @@ -2,9 +2,14 @@ import 'package:design_system/design_system.dart'; import 'package:flutter/material.dart'; class AttireUploadButtons extends StatelessWidget { - const AttireUploadButtons({super.key, required this.onUpload}); + const AttireUploadButtons({ + super.key, + required this.onGallery, + required this.onCamera, + }); - final void Function(BuildContext) onUpload; + final VoidCallback onGallery; + final VoidCallback onCamera; @override Widget build(BuildContext context) { @@ -14,7 +19,7 @@ class AttireUploadButtons extends StatelessWidget { child: UiButton.secondary( leadingIcon: UiIcons.gallery, text: 'Gallery', - onPressed: () => onUpload(context), + onPressed: onGallery, ), ), const SizedBox(width: UiConstants.space4), @@ -22,7 +27,7 @@ class AttireUploadButtons extends StatelessWidget { child: UiButton.primary( leadingIcon: UiIcons.camera, text: 'Camera', - onPressed: () => onUpload(context), + onPressed: onCamera, ), ), ], diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/footer_section.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/footer_section.dart new file mode 100644 index 00000000..6f0b4c2e --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/footer_section.dart @@ -0,0 +1,109 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'attire_upload_buttons.dart'; + +/// Handles the primary actions at the bottom of the page. +class FooterSection extends StatelessWidget { + /// Creates a [FooterSection]. + const FooterSection({ + super.key, + required this.isUploading, + this.selectedLocalPath, + required this.hasVerificationStatus, + required this.hasUploadedPhoto, + this.updatedItem, + required this.onGallery, + required this.onCamera, + required this.onSubmit, + required this.onReupload, + }); + + /// Whether a photo is currently being uploaded. + final bool isUploading; + + /// The local path of the selected photo. + final String? selectedLocalPath; + + /// Whether the item already has a verification status. + final bool hasVerificationStatus; + + /// Whether the item has an uploaded photo. + final bool hasUploadedPhoto; + + /// The updated attire item, if any. + final AttireItem? updatedItem; + + /// Callback to open the gallery. + final VoidCallback onGallery; + + /// Callback to open the camera. + final VoidCallback onCamera; + + /// Callback to submit the photo. + final VoidCallback onSubmit; + + /// Callback to trigger the re-upload flow. + final VoidCallback onReupload; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (isUploading) + const Center( + child: Padding( + padding: EdgeInsets.all(UiConstants.space4), + child: CircularProgressIndicator(), + ), + ) + else + _buildActionButtons(), + ], + ), + ), + ); + } + + Widget _buildActionButtons() { + if (selectedLocalPath != null) { + return UiButton.primary( + fullWidth: true, + text: 'Submit Image', + onPressed: onSubmit, + ); + } + + if (hasVerificationStatus) { + return UiButton.secondary( + fullWidth: true, + text: 'Re Upload', + onPressed: onReupload, + ); + } + + return Column( + children: [ + AttireUploadButtons(onGallery: onGallery, onCamera: onCamera), + if (hasUploadedPhoto) ...[ + const SizedBox(height: UiConstants.space4), + UiButton.primary( + fullWidth: true, + text: 'Submit Image', + onPressed: () { + if (updatedItem != null) { + Modular.to.pop(updatedItem); + } + }, + ), + ], + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/image_preview_section.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/image_preview_section.dart new file mode 100644 index 00000000..18a6e930 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/image_preview_section.dart @@ -0,0 +1,96 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'attire_image_preview.dart'; + +/// Displays the comparison between the reference example and the user's photo. +class ImagePreviewSection extends StatelessWidget { + /// Creates an [ImagePreviewSection]. + const ImagePreviewSection({ + super.key, + this.selectedLocalPath, + this.currentPhotoUrl, + this.referenceImageUrl, + }); + + /// The local file path of the selected image. + final String? selectedLocalPath; + + /// The URL of the currently uploaded photo. + final String? currentPhotoUrl; + + /// The URL of the reference example image. + final String? referenceImageUrl; + + @override + Widget build(BuildContext context) { + if (selectedLocalPath != null) { + return Column( + children: [ + Text( + 'Review the attire item', + style: UiTypography.body1b.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + AttireImagePreview(localPath: selectedLocalPath), + const SizedBox(height: UiConstants.space4), + ReferenceExample(imageUrl: referenceImageUrl), + ], + ); + } + + if (currentPhotoUrl != null) { + return Column( + children: [ + Text('Your Uploaded Photo', style: UiTypography.body1b.textPrimary), + const SizedBox(height: UiConstants.space2), + AttireImagePreview(imageUrl: currentPhotoUrl), + const SizedBox(height: UiConstants.space4), + ReferenceExample(imageUrl: referenceImageUrl), + ], + ); + } + + return Column( + children: [ + AttireImagePreview(imageUrl: referenceImageUrl), + const SizedBox(height: UiConstants.space4), + Text( + 'Example of the item that you need to upload.', + style: UiTypography.body1b.textSecondary, + textAlign: TextAlign.center, + ), + ], + ); + } +} + +/// Displays the reference item photo as an example. +class ReferenceExample extends StatelessWidget { + /// Creates a [ReferenceExample]. + const ReferenceExample({super.key, this.imageUrl}); + + /// The URL of the image to display. + final String? imageUrl; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text('Reference Example', style: UiTypography.body2b.textSecondary), + const SizedBox(height: UiConstants.space1), + Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + child: Image.network( + imageUrl ?? '', + height: 120, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/info_section.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/info_section.dart new file mode 100644 index 00000000..be5995f2 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/info_section.dart @@ -0,0 +1,89 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../attestation_checkbox.dart'; +import 'attire_verification_status_card.dart'; + +/// Displays the item details, verification status, and attestation checkbox. +class InfoSection extends StatelessWidget { + /// Creates an [InfoSection]. + const InfoSection({ + super.key, + this.description, + required this.statusText, + required this.statusColor, + required this.isPending, + required this.showCheckbox, + required this.isAttested, + required this.onAttestationChanged, + }); + + /// The description of the attire item. + final String? description; + + /// The text to display for the verification status. + final String statusText; + + /// The color to use for the verification status text. + final Color statusColor; + + /// Whether the item is currently pending verification. + final bool isPending; + + /// Whether to show the attestation checkbox. + final bool showCheckbox; + + /// Whether the user has attested to owning the item. + final bool isAttested; + + /// Callback when the attestation status changes. + final ValueChanged onAttestationChanged; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + if (description != null) + Text( + description!, + style: UiTypography.body1r.textSecondary, + textAlign: TextAlign.center, + ), + const SizedBox(height: UiConstants.space8), + + // Pending Banner + if (isPending) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.tagPending, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + child: Text( + 'A Manager will Verify This Item', + style: UiTypography.body2b.textWarning, + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: UiConstants.space4), + ], + + // Verification info + AttireVerificationStatusCard( + statusText: statusText, + statusColor: statusColor, + ), + const SizedBox(height: UiConstants.space6), + + if (showCheckbox) ...[ + AttestationCheckbox( + isChecked: isAttested, + onChanged: onAttestationChanged, + ), + const SizedBox(height: UiConstants.space6), + ], + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_item_card.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_item_card.dart index 43c88fbc..f0941d96 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_item_card.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_item_card.dart @@ -3,11 +3,6 @@ import 'package:flutter/material.dart'; import 'package:krow_domain/krow_domain.dart'; class AttireItemCard extends StatelessWidget { - final AttireItem item; - final String? uploadedPhotoUrl; - final bool isUploading; - final VoidCallback onTap; - const AttireItemCard({ super.key, required this.item, @@ -16,12 +11,17 @@ class AttireItemCard extends StatelessWidget { required this.onTap, }); + final AttireItem item; + final String? uploadedPhotoUrl; + final bool isUploading; + final VoidCallback onTap; + @override Widget build(BuildContext context) { final bool hasPhoto = item.photoUrl != null; final String statusText = switch (item.verificationStatus) { - AttireVerificationStatus.success => 'Approved', - AttireVerificationStatus.failed => 'Rejected', + AttireVerificationStatus.approved => 'Approved', + AttireVerificationStatus.rejected => 'Rejected', AttireVerificationStatus.pending => 'Pending', _ => hasPhoto ? 'Pending' : 'To Do', }; @@ -89,7 +89,9 @@ class AttireItemCard extends StatelessWidget { UiChip( label: statusText, size: UiChipSize.xSmall, - variant: item.verificationStatus == 'SUCCESS' + variant: + item.verificationStatus == + AttireVerificationStatus.approved ? UiChipVariant.primary : UiChipVariant.secondary, ), @@ -112,10 +114,12 @@ class AttireItemCard extends StatelessWidget { ) else if (hasPhoto && !isUploading) Icon( - item.verificationStatus == 'SUCCESS' + item.verificationStatus == AttireVerificationStatus.approved ? UiIcons.check : UiIcons.clock, - color: item.verificationStatus == 'SUCCESS' + color: + item.verificationStatus == + AttireVerificationStatus.approved ? UiColors.textPrimary : UiColors.textWarning, size: 24, diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml index 07a124c8..0a5ffcf0 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: path: ../../../../../design_system core_localization: path: ../../../../../core_localization + image_picker: ^1.2.1 dev_dependencies: flutter_test: diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 777d1470..1270ef05 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -241,6 +241,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.15.0" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937" + url: "https://pub.dev" + source: hosted + version: "0.3.5+2" crypto: dependency: transitive description: @@ -337,6 +345,46 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.1" + file_picker: + dependency: transitive + description: + name: file_picker + sha256: ab13ae8ef5580a411c458d6207b6774a6c237d77ac37011b13994879f68a8810 + url: "https://pub.dev" + source: hosted + version: "8.3.7" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.dev" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" + url: "https://pub.dev" + source: hosted + version: "0.9.5" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.dev" + source: hosted + version: "0.9.3+5" firebase_app_check: dependency: transitive description: @@ -725,6 +773,70 @@ packages: url: "https://pub.dev" source: hosted version: "4.3.0" + image_picker: + dependency: transitive + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: eda9b91b7e266d9041084a42d605a74937d996b87083395c5e47835916a86156 + url: "https://pub.dev" + source: hosted + version: "0.8.13+14" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 + url: "https://pub.dev" + source: hosted + version: "0.8.13+6" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.dev" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" + url: "https://pub.dev" + source: hosted + version: "0.2.2+1" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.dev" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.dev" + source: hosted + version: "0.2.2" intl: dependency: transitive description: @@ -1504,6 +1616,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.dev" + source: hosted + version: "5.15.0" xdg_directories: dependency: transitive description: diff --git a/backend/dataconnect/connector/staffAttire/mutations.gql b/backend/dataconnect/connector/staffAttire/mutations.gql index 25184389..72fa489b 100644 --- a/backend/dataconnect/connector/staffAttire/mutations.gql +++ b/backend/dataconnect/connector/staffAttire/mutations.gql @@ -3,6 +3,7 @@ mutation upsertStaffAttire( $attireOptionId: UUID! $verificationPhotoUrl: String $verificationId: String + $verificationStatus: AttireVerificationStatus ) @auth(level: USER) { staffAttire_upsert( data: { @@ -10,7 +11,7 @@ mutation upsertStaffAttire( attireOptionId: $attireOptionId verificationPhotoUrl: $verificationPhotoUrl verificationId: $verificationId - verificationStatus: PENDING + verificationStatus: $verificationStatus } ) } diff --git a/backend/dataconnect/schema/staffAttire.gql b/backend/dataconnect/schema/staffAttire.gql index e61e8f9b..c3f0e213 100644 --- a/backend/dataconnect/schema/staffAttire.gql +++ b/backend/dataconnect/schema/staffAttire.gql @@ -1,7 +1,12 @@ enum AttireVerificationStatus { PENDING - FAILED - SUCCESS + PROCESSING + AUTO_PASS + AUTO_FAIL + NEEDS_REVIEW + APPROVED + REJECTED + ERROR } type StaffAttire @table(name: "staff_attires", key: ["staffId", "attireOptionId"]) { diff --git a/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md b/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md new file mode 100644 index 00000000..64f8a5c2 --- /dev/null +++ b/docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md @@ -0,0 +1,245 @@ +# M4 Core API Frontend Guide (Dev) + +Status: Active +Last updated: 2026-02-24 +Audience: Web and mobile frontend developers + +## 1) Base URLs (dev) +1. Core API: `https://krow-core-api-e3g6witsvq-uc.a.run.app` + +## 2) Auth requirements +1. Send Firebase ID token on protected routes: +```http +Authorization: Bearer +``` +2. Health route is public: +- `GET /health` +3. All other routes require Firebase token. + +## 3) Standard error envelope +```json +{ + "code": "STRING_CODE", + "message": "Human readable message", + "details": {}, + "requestId": "uuid" +} +``` + +## 4) Core API endpoints + +## 4.1 Upload file +1. Route: `POST /core/upload-file` +2. Alias: `POST /uploadFile` +3. Content type: `multipart/form-data` +4. Form fields: +- `file` (required) +- `visibility` (optional: `public` or `private`, default `private`) +- `category` (optional) +5. Accepted file types: +- `application/pdf` +- `image/jpeg` +- `image/jpg` +- `image/png` +6. Max upload size: `10 MB` (default) +7. Current behavior: real upload to Cloud Storage (not mock) +8. Success `200` example: +```json +{ + "fileUri": "gs://krow-workforce-dev-private/uploads//173...", + "contentType": "application/pdf", + "size": 12345, + "bucket": "krow-workforce-dev-private", + "path": "uploads//173..._file.pdf", + "requestId": "uuid" +} +``` + +## 4.2 Create signed URL +1. Route: `POST /core/create-signed-url` +2. Alias: `POST /createSignedUrl` +3. Request body: +```json +{ + "fileUri": "gs://krow-workforce-dev-private/uploads//file.pdf", + "expiresInSeconds": 300 +} +``` +4. Security checks: +- bucket must be allowed (`krow-workforce-dev-public` or `krow-workforce-dev-private`) +- path must be owned by caller (`uploads//...`) +- object must exist +- `expiresInSeconds` must be `<= 900` +5. Success `200` example: +```json +{ + "signedUrl": "https://storage.googleapis.com/...", + "expiresAt": "2026-02-24T15:22:28.105Z", + "requestId": "uuid" +} +``` +6. Typical errors: +- `400 VALIDATION_ERROR` (bad payload or expiry too high) +- `403 FORBIDDEN` (path not owned by caller) +- `404 NOT_FOUND` (object does not exist) + +## 4.3 Invoke model +1. Route: `POST /core/invoke-llm` +2. Alias: `POST /invokeLLM` +3. Request body: +```json +{ + "prompt": "Return JSON with keys summary and risk.", + "responseJsonSchema": { + "type": "object", + "properties": { + "summary": { "type": "string" }, + "risk": { "type": "string" } + }, + "required": ["summary", "risk"] + }, + "fileUrls": [] +} +``` +4. Current behavior: real Vertex model call (not mock) +- model: `gemini-2.0-flash-001` +- timeout: `20 seconds` +5. Rate limit: +- per-user `20 requests/minute` (default) +- on limit: `429 RATE_LIMITED` +- includes `Retry-After` header +6. Success `200` example: +```json +{ + "result": { "summary": "text", "risk": "Low" }, + "model": "gemini-2.0-flash-001", + "latencyMs": 367, + "requestId": "uuid" +} +``` + +## 4.4 Create verification job +1. Route: `POST /core/verifications` +2. Auth: required +3. Purpose: enqueue an async verification job for an uploaded file. +4. Request body: +```json +{ + "type": "attire", + "subjectType": "worker", + "subjectId": "", + "fileUri": "gs://krow-workforce-dev-private/uploads//file.pdf", + "rules": { + "dressCode": "black shoes" + } +} +``` +5. Success `202` example: +```json +{ + "verificationId": "ver_123", + "status": "PENDING", + "type": "attire", + "requestId": "uuid" +} +``` +6. Current machine processing behavior in dev: +- `attire`: live vision check using Vertex Gemini Flash Lite model. +- `government_id`: third-party adapter path (falls back to `NEEDS_REVIEW` if provider is not configured). +- `certification`: third-party adapter path (falls back to `NEEDS_REVIEW` if provider is not configured). + +## 4.5 Get verification status +1. Route: `GET /core/verifications/{verificationId}` +2. Auth: required +3. Purpose: polling status from frontend. +4. Success `200` example: +```json +{ + "verificationId": "ver_123", + "status": "NEEDS_REVIEW", + "type": "attire", + "review": null, + "requestId": "uuid" +} +``` + +## 4.6 Review verification +1. Route: `POST /core/verifications/{verificationId}/review` +2. Auth: required +3. Purpose: final human decision for the verification. +4. Request body: +```json +{ + "decision": "APPROVED", + "note": "Manual review passed", + "reasonCode": "MANUAL_REVIEW" +} +``` +5. Success `200` example: +```json +{ + "verificationId": "ver_123", + "status": "APPROVED", + "review": { + "decision": "APPROVED", + "reviewedBy": "" + }, + "requestId": "uuid" +} +``` + +## 4.7 Retry verification +1. Route: `POST /core/verifications/{verificationId}/retry` +2. Auth: required +3. Purpose: requeue verification to run again. +4. Success `202` example: status resets to `PENDING`. + +## 5) Frontend fetch examples (web) + +## 5.1 Signed URL request +```ts +const token = await firebaseAuth.currentUser?.getIdToken(); +const res = await fetch('https://krow-core-api-e3g6witsvq-uc.a.run.app/core/create-signed-url', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + fileUri: 'gs://krow-workforce-dev-private/uploads//file.pdf', + expiresInSeconds: 300, + }), +}); +const data = await res.json(); +``` + +## 5.2 Model request +```ts +const token = await firebaseAuth.currentUser?.getIdToken(); +const res = await fetch('https://krow-core-api-e3g6witsvq-uc.a.run.app/core/invoke-llm', { + method: 'POST', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + prompt: 'Return JSON with status.', + responseJsonSchema: { + type: 'object', + properties: { status: { type: 'string' } }, + required: ['status'], + }, + }), +}); +const data = await res.json(); +``` + +## 6) Notes for frontend team +1. Use canonical `/core/*` routes for new work. +2. Aliases exist only for migration compatibility. +3. `requestId` in responses should be logged client-side for debugging. +4. For 429 on model route, retry with exponential backoff and respect `Retry-After`. +5. Verification routes are now available in dev under `/core/verifications*`. +6. Current verification processing is async and returns machine statuses first (`PENDING`, `PROCESSING`, `NEEDS_REVIEW`, etc.). +7. Full verification design and policy details: + `docs/MILESTONES/M4/planning/m4-verification-architecture-contract.md`.