Merge pull request #541 from Oloodi/503-build-dedicated-interface-to-display-hub-details
Integrated the staff app attire interface
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -43,6 +43,7 @@ lerna-debug.log*
|
||||
*.temp
|
||||
tmp/
|
||||
temp/
|
||||
scripts/issues-to-create.md
|
||||
|
||||
# ==============================================================================
|
||||
# SECURITY (CRITICAL)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
|
||||
#if __has_include(<file_picker/FilePickerPlugin.h>)
|
||||
#import <file_picker/FilePickerPlugin.h>
|
||||
#else
|
||||
@import file_picker;
|
||||
#endif
|
||||
|
||||
#if __has_include(<firebase_app_check/FLTFirebaseAppCheckPlugin.h>)
|
||||
#import <firebase_app_check/FLTFirebaseAppCheckPlugin.h>
|
||||
#else
|
||||
@@ -24,6 +30,12 @@
|
||||
@import firebase_core;
|
||||
#endif
|
||||
|
||||
#if __has_include(<image_picker_ios/FLTImagePickerPlugin.h>)
|
||||
#import <image_picker_ios/FLTImagePickerPlugin.h>
|
||||
#else
|
||||
@import image_picker_ios;
|
||||
#endif
|
||||
|
||||
#if __has_include(<shared_preferences_foundation/SharedPreferencesPlugin.h>)
|
||||
#import <shared_preferences_foundation/SharedPreferencesPlugin.h>
|
||||
#else
|
||||
@@ -39,9 +51,11 @@
|
||||
@implementation GeneratedPluginRegistrant
|
||||
|
||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)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"]];
|
||||
}
|
||||
|
||||
@@ -6,9 +6,13 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
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);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -6,11 +6,14 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <firebase_auth/firebase_auth_plugin_c_api.h>
|
||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FirebaseAuthPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
|
||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
firebase_auth
|
||||
firebase_core
|
||||
url_launcher_windows
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
|
||||
#if __has_include(<file_picker/FilePickerPlugin.h>)
|
||||
#import <file_picker/FilePickerPlugin.h>
|
||||
#else
|
||||
@import file_picker;
|
||||
#endif
|
||||
|
||||
#if __has_include(<firebase_app_check/FLTFirebaseAppCheckPlugin.h>)
|
||||
#import <firebase_app_check/FLTFirebaseAppCheckPlugin.h>
|
||||
#else
|
||||
@@ -36,6 +42,12 @@
|
||||
@import google_maps_flutter_ios;
|
||||
#endif
|
||||
|
||||
#if __has_include(<image_picker_ios/FLTImagePickerPlugin.h>)
|
||||
#import <image_picker_ios/FLTImagePickerPlugin.h>
|
||||
#else
|
||||
@import image_picker_ios;
|
||||
#endif
|
||||
|
||||
#if __has_include(<permission_handler_apple/PermissionHandlerPlugin.h>)
|
||||
#import <permission_handler_apple/PermissionHandlerPlugin.h>
|
||||
#else
|
||||
@@ -57,11 +69,13 @@
|
||||
@implementation GeneratedPluginRegistrant
|
||||
|
||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)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"]];
|
||||
|
||||
@@ -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: <String>['STAFF', 'BOTH'], // Only allow users with STAFF or BOTH roles
|
||||
allowedRoles: <String>[
|
||||
'STAFF',
|
||||
'BOTH',
|
||||
], // Only allow users with STAFF or BOTH roles
|
||||
);
|
||||
|
||||
runApp(
|
||||
@@ -60,8 +64,8 @@ void main() async {
|
||||
/// The main application module.
|
||||
class AppModule extends Module {
|
||||
@override
|
||||
List<Module> get imports =>
|
||||
<Module>[
|
||||
List<Module> get imports => <Module>[
|
||||
CoreModule(),
|
||||
core_localization.LocalizationModule(),
|
||||
staff_authentication.StaffAuthenticationModule(),
|
||||
];
|
||||
|
||||
@@ -6,9 +6,13 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
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);
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <firebase_auth/firebase_auth_plugin_c_api.h>
|
||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||
#include <geolocator_windows/geolocator_windows.h>
|
||||
@@ -13,6 +14,8 @@
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FirebaseAuthPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
|
||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
firebase_auth
|
||||
firebase_core
|
||||
geolocator_windows
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
|
||||
48
apps/mobile/packages/core/lib/src/core_module.dart
Normal file
48
apps/mobile/packages/core/lib/src/core_module.dart
Normal file
@@ -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<Dio>(() => DioClient());
|
||||
|
||||
// 2. Register the base API service
|
||||
i.addSingleton<BaseApiService>(() => ApiService(i.get<Dio>()));
|
||||
|
||||
// 3. Register Core API Services (Orchestrators)
|
||||
i.addSingleton<FileUploadService>(
|
||||
() => FileUploadService(i.get<BaseApiService>()),
|
||||
);
|
||||
i.addSingleton<SignedUrlService>(
|
||||
() => SignedUrlService(i.get<BaseApiService>()),
|
||||
);
|
||||
i.addSingleton<VerificationService>(
|
||||
() => VerificationService(i.get<BaseApiService>()),
|
||||
);
|
||||
i.addSingleton<LlmService>(() => LlmService(i.get<BaseApiService>()));
|
||||
|
||||
// 4. Register Device dependency
|
||||
i.addSingleton<ImagePicker>(() => ImagePicker());
|
||||
|
||||
// 5. Register Device Services
|
||||
i.addSingleton<CameraService>(() => CameraService(i.get<ImagePicker>()));
|
||||
i.addSingleton<GalleryService>(() => GalleryService(i.get<ImagePicker>()));
|
||||
i.addSingleton<FilePickerService>(FilePickerService.new);
|
||||
i.addSingleton<DeviceFileUploadService>(
|
||||
() => DeviceFileUploadService(
|
||||
cameraService: i.get<CameraService>(),
|
||||
galleryService: i.get<GalleryService>(),
|
||||
apiUploadService: i.get<FileUploadService>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: <String, dynamic>{
|
||||
'item': item,
|
||||
'initialPhotoUrl': initialPhotoUrl,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
|
||||
@@ -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
|
||||
// ==========================================================================
|
||||
|
||||
@@ -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<ApiResponse> get(
|
||||
String endpoint, {
|
||||
Map<String, dynamic>? params,
|
||||
}) async {
|
||||
try {
|
||||
final Response<dynamic> response = await _dio.get<dynamic>(
|
||||
endpoint,
|
||||
queryParameters: params,
|
||||
);
|
||||
return _handleResponse(response);
|
||||
} on DioException catch (e) {
|
||||
return _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs a POST request to the specified [endpoint].
|
||||
@override
|
||||
Future<ApiResponse> post(
|
||||
String endpoint, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? params,
|
||||
}) async {
|
||||
try {
|
||||
final Response<dynamic> response = await _dio.post<dynamic>(
|
||||
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<ApiResponse> put(
|
||||
String endpoint, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? params,
|
||||
}) async {
|
||||
try {
|
||||
final Response<dynamic> response = await _dio.put<dynamic>(
|
||||
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<ApiResponse> patch(
|
||||
String endpoint, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? params,
|
||||
}) async {
|
||||
try {
|
||||
final Response<dynamic> response = await _dio.patch<dynamic>(
|
||||
endpoint,
|
||||
data: data,
|
||||
queryParameters: params,
|
||||
);
|
||||
return _handleResponse(response);
|
||||
} on DioException catch (e) {
|
||||
return _handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Extracts [ApiResponse] from a successful [Response].
|
||||
ApiResponse _handleResponse(Response<dynamic> 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<String, dynamic>) {
|
||||
final Map<String, dynamic> body =
|
||||
e.response!.data as Map<String, dynamic>;
|
||||
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: <String, dynamic>{'exception': e.type.toString()},
|
||||
);
|
||||
}
|
||||
|
||||
/// Helper to parse the errors map from various possible formats.
|
||||
Map<String, dynamic> _parseErrors(dynamic errors) {
|
||||
if (errors is Map) {
|
||||
return Map<String, dynamic>.from(errors);
|
||||
}
|
||||
return const <String, dynamic>{};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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<String, dynamic> 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<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'fileUri': fileUri,
|
||||
'contentType': contentType,
|
||||
'size': size,
|
||||
'bucket': bucket,
|
||||
'path': path,
|
||||
'requestId': requestId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<FileUploadResponse> 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(<String, dynamic>{
|
||||
'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<String, dynamic>);
|
||||
}
|
||||
|
||||
throw Exception(res.message);
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> json) {
|
||||
return LlmResponse(
|
||||
result: json['result'] as Map<String, dynamic>,
|
||||
model: json['model'] as String,
|
||||
latencyMs: json['latencyMs'] as int,
|
||||
requestId: json['requestId'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
/// The JSON result returned by the model.
|
||||
final Map<String, dynamic> 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<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'result': result,
|
||||
'model': model,
|
||||
'latencyMs': latencyMs,
|
||||
'requestId': requestId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<LlmResponse> invokeLlm({
|
||||
required String prompt,
|
||||
Map<String, dynamic>? responseJsonSchema,
|
||||
List<String>? fileUrls,
|
||||
}) async {
|
||||
final ApiResponse res = await action(() async {
|
||||
return api.post(
|
||||
CoreApiEndpoints.invokeLlm,
|
||||
data: <String, dynamic>{
|
||||
'prompt': prompt,
|
||||
if (responseJsonSchema != null)
|
||||
'responseJsonSchema': responseJsonSchema,
|
||||
if (fileUrls != null) 'fileUrls': fileUrls,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (res.code.startsWith('2')) {
|
||||
return LlmResponse.fromJson(res.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
throw Exception(res.message);
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> 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<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'signedUrl': signedUrl,
|
||||
'expiresAt': expiresAt.toIso8601String(),
|
||||
'requestId': requestId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<SignedUrlResponse> createSignedUrl({
|
||||
required String fileUri,
|
||||
int expiresInSeconds = 300,
|
||||
}) async {
|
||||
final ApiResponse res = await action(() async {
|
||||
return api.post(
|
||||
CoreApiEndpoints.createSignedUrl,
|
||||
data: <String, dynamic>{
|
||||
'fileUri': fileUri,
|
||||
'expiresInSeconds': expiresInSeconds,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (res.code.startsWith('2')) {
|
||||
return SignedUrlResponse.fromJson(res.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
throw Exception(res.message);
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> 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<String, dynamic>
|
||||
: 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<String, dynamic>? review;
|
||||
|
||||
/// The unique request ID from the server.
|
||||
final String? requestId;
|
||||
|
||||
/// Converts the response to a JSON map.
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'verificationId': verificationId,
|
||||
'status': status.value,
|
||||
'type': type,
|
||||
'review': review,
|
||||
'requestId': requestId,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<VerificationResponse> createVerification({
|
||||
required String type,
|
||||
required String subjectType,
|
||||
required String subjectId,
|
||||
required String fileUri,
|
||||
Map<String, dynamic>? rules,
|
||||
}) async {
|
||||
final ApiResponse res = await action(() async {
|
||||
return api.post(
|
||||
CoreApiEndpoints.verifications,
|
||||
data: <String, dynamic>{
|
||||
'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<String, dynamic>);
|
||||
}
|
||||
|
||||
throw Exception(res.message);
|
||||
}
|
||||
|
||||
/// Polls the status of a specific verification.
|
||||
Future<VerificationResponse> 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<String, dynamic>);
|
||||
}
|
||||
|
||||
throw Exception(res.message);
|
||||
}
|
||||
|
||||
/// Submits a manual review decision.
|
||||
///
|
||||
/// [decision] should be 'APPROVED' or 'REJECTED'.
|
||||
Future<VerificationResponse> 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: <String, dynamic>{
|
||||
'decision': decision,
|
||||
if (note != null) 'note': note,
|
||||
if (reasonCode != null) 'reasonCode': reasonCode,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
if (res.code.startsWith('2')) {
|
||||
return VerificationResponse.fromJson(res.data as Map<String, dynamic>);
|
||||
}
|
||||
|
||||
throw Exception(res.message);
|
||||
}
|
||||
|
||||
/// Retries a verification job that failed or needs re-processing.
|
||||
Future<VerificationResponse> 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<String, dynamic>);
|
||||
}
|
||||
|
||||
throw Exception(res.message);
|
||||
}
|
||||
}
|
||||
@@ -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(<Interceptor>[
|
||||
AuthInterceptor(),
|
||||
LogInterceptor(
|
||||
requestBody: true,
|
||||
responseBody: true,
|
||||
), // Added for better debugging
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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<void> 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);
|
||||
}
|
||||
}
|
||||
@@ -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<String?> takePhoto() async {
|
||||
return action(() async {
|
||||
final XFile? file = await _picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
imageQuality: 80,
|
||||
);
|
||||
return file?.path;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<String?> pickFile({List<String>? 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<FileUploadResponse?> 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<FileUploadResponse?> 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<String?> pickImage() async {
|
||||
return action(() async {
|
||||
final XFile? file = await _picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
imageQuality: 80,
|
||||
);
|
||||
return file?.path;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<bool> 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<GetStaffProfileCompletionEmergencyContacts> emergencyContacts =
|
||||
response.data.emergencyContacts;
|
||||
final List<GetStaffProfileCompletionTaxForms> taxForms =
|
||||
final dc.GetStaffProfileCompletionStaff? staff = response.data.staff;
|
||||
final List<dc.GetStaffProfileCompletionEmergencyContacts>
|
||||
emergencyContacts = response.data.emergencyContacts;
|
||||
final List<dc.GetStaffProfileCompletionTaxForms> 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<String>? skills = staff.skills;
|
||||
final List<String>? 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<GetStaffProfileCompletionEmergencyContacts> emergencyContacts,
|
||||
List<GetStaffProfileCompletionTaxForms> taxForms,
|
||||
dc.GetStaffProfileCompletionStaff? staff,
|
||||
List<dc.GetStaffProfileCompletionEmergencyContacts> emergencyContacts,
|
||||
List<dc.GetStaffProfileCompletionTaxForms> taxForms,
|
||||
) {
|
||||
if (staff == null) return false;
|
||||
final dynamic skills = staff.skills;
|
||||
final dynamic industries = staff.industries;
|
||||
|
||||
final List<String>? skills = staff.skills;
|
||||
final List<String>? 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<Staff> getStaffProfile() async {
|
||||
Future<domain.Staff> getStaffProfile() async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
final QueryResult<GetStaffByIdData, GetStaffByIdVariables> response =
|
||||
await _service.connector.getStaffById(id: staffId).execute();
|
||||
final QueryResult<dc.GetStaffByIdData, dc.GetStaffByIdVariables>
|
||||
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<List<Benefit>> getBenefits() async {
|
||||
Future<List<domain.Benefit>> 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<List<AttireItem>> getAttireOptions() async {
|
||||
Future<List<domain.AttireItem>> getAttireOptions() async {
|
||||
return _service.run(() async {
|
||||
final String staffId = await _service.getStaffId();
|
||||
|
||||
// Fetch all options
|
||||
final QueryResult<ListAttireOptionsData, void> optionsResponse =
|
||||
await _service.connector.listAttireOptions().execute();
|
||||
final List<QueryResult<Object, Object?>> results =
|
||||
await Future.wait<QueryResult<Object, Object?>>(
|
||||
<Future<QueryResult<Object, Object?>>>[
|
||||
_service.connector.listAttireOptions().execute(),
|
||||
_service.connector.getStaffAttire(staffId: staffId).execute(),
|
||||
],
|
||||
);
|
||||
|
||||
// Fetch user's attire status
|
||||
final QueryResult<GetStaffAttireData, GetStaffAttireVariables>
|
||||
attiresResponse = await _service.connector
|
||||
.getStaffAttire(staffId: staffId)
|
||||
.execute();
|
||||
final QueryResult<dc.ListAttireOptionsData, void> optionsRes =
|
||||
results[0] as QueryResult<dc.ListAttireOptionsData, void>;
|
||||
final QueryResult<dc.GetStaffAttireData, dc.GetStaffAttireVariables>
|
||||
staffAttireRes =
|
||||
results[1]
|
||||
as QueryResult<dc.GetStaffAttireData, dc.GetStaffAttireVariables>;
|
||||
|
||||
final Map<String, GetStaffAttireStaffAttires> attireMap = {
|
||||
for (final item in attiresResponse.data.staffAttires)
|
||||
item.attireOptionId: item,
|
||||
};
|
||||
final List<dc.GetStaffAttireStaffAttires> 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,
|
||||
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,
|
||||
),
|
||||
photoUrl: userAttire?.verificationPhotoUrl,
|
||||
);
|
||||
|
||||
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<void> 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<dc.AttireVerificationStatus> status,
|
||||
) {
|
||||
if (status is dc.Unknown) {
|
||||
return domain.AttireVerificationStatus.error;
|
||||
}
|
||||
final String name =
|
||||
(status as dc.Known<dc.AttireVerificationStatus>).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<void> 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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<void> signOut();
|
||||
|
||||
/// Saves the staff profile information.
|
||||
Future<void> saveStaffProfile({
|
||||
String? firstName,
|
||||
String? lastName,
|
||||
String? bio,
|
||||
String? profilePictureUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 <String, dynamic>{},
|
||||
});
|
||||
|
||||
/// 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<String, dynamic> errors;
|
||||
}
|
||||
@@ -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<ApiResponse> get(String endpoint, {Map<String, dynamic>? params});
|
||||
|
||||
/// Performs a POST request to the specified [endpoint].
|
||||
Future<ApiResponse> post(
|
||||
String endpoint, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? params,
|
||||
});
|
||||
|
||||
/// Performs a PUT request to the specified [endpoint].
|
||||
Future<ApiResponse> put(
|
||||
String endpoint, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? params,
|
||||
});
|
||||
|
||||
/// Performs a PATCH request to the specified [endpoint].
|
||||
Future<ApiResponse> patch(
|
||||
String endpoint, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? params,
|
||||
});
|
||||
}
|
||||
@@ -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<ApiResponse> action(Future<ApiResponse> Function() execution) async {
|
||||
try {
|
||||
return await execution();
|
||||
} catch (e) {
|
||||
return ApiResponse(
|
||||
code: 'CORE_INTERNAL_ERROR',
|
||||
message: e.toString(),
|
||||
errors: <String, dynamic>{'exception': e.runtimeType.toString()},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<T> action<T>(Future<T> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Object?> get props => <Object?>[
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_linux/file_selector_plugin.h>
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -6,10 +6,13 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <firebase_auth/firebase_auth_plugin_c_api.h>
|
||||
#include <firebase_core/firebase_core_plugin_c_api.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
FirebaseAuthPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi"));
|
||||
FirebaseCorePluginCApiRegisterWithRegistrar(
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
firebase_auth
|
||||
firebase_core
|
||||
)
|
||||
|
||||
@@ -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>(ImagePicker.new);
|
||||
|
||||
/// local services
|
||||
i.addLazySingleton<CameraService>(
|
||||
() => CameraService(i.get<ImagePicker>()),
|
||||
);
|
||||
|
||||
// Repository
|
||||
i.addLazySingleton<AttireRepository>(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?,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> 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<AttireItem> uploadPhoto(String itemId, String filePath) async {
|
||||
// 1. Upload file to Core API
|
||||
final FileUploadService uploadService = Modular.get<FileUploadService>();
|
||||
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<SignedUrlService>();
|
||||
final SignedUrlResponse signedUrlRes = await signedUrlService
|
||||
.createSignedUrl(fileUri: fileUri);
|
||||
final String photoUrl = signedUrlRes.signedUrl;
|
||||
|
||||
// 3. Initiate verification job
|
||||
final VerificationService verificationService =
|
||||
Modular.get<VerificationService>();
|
||||
final Staff staff = await _connector.getStaffProfile();
|
||||
|
||||
// Get item details for verification rules
|
||||
final List<AttireItem> 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: <String, dynamic>{'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<void>.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<AttireItem> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Object?> get props => <Object?>[itemId];
|
||||
List<Object?> get props => <Object?>[itemId, filePath];
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ abstract interface class AttireRepository {
|
||||
/// Fetches the list of available attire options.
|
||||
Future<List<AttireItem>> getAttireOptions();
|
||||
|
||||
/// Simulates uploading a photo for a specific attire item.
|
||||
Future<String> uploadPhoto(String itemId);
|
||||
/// Uploads a photo for a specific attire item.
|
||||
Future<AttireItem> uploadPhoto(String itemId, String filePath);
|
||||
|
||||
/// Saves the user's attire selection and attestations.
|
||||
Future<void> saveAttire({
|
||||
|
||||
@@ -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<UploadAttirePhotoArguments, String> {
|
||||
|
||||
class UploadAttirePhotoUseCase
|
||||
extends UseCase<UploadAttirePhotoArguments, AttireItem> {
|
||||
/// Creates a [UploadAttirePhotoUseCase].
|
||||
UploadAttirePhotoUseCase(this._repository);
|
||||
final AttireRepository _repository;
|
||||
|
||||
@override
|
||||
Future<String> call(UploadAttirePhotoArguments arguments) {
|
||||
return _repository.uploadPhoto(arguments.itemId);
|
||||
Future<AttireItem> call(UploadAttirePhotoArguments arguments) {
|
||||
return _repository.uploadPhoto(arguments.itemId, arguments.filePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,9 +64,25 @@ class AttireCubit extends Cubit<AttireState>
|
||||
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<AttireItem> updatedOptions = state.options
|
||||
.map((AttireItem e) => e.id == item.id ? item : e)
|
||||
.toList();
|
||||
|
||||
// Update the photo URLs map
|
||||
final Map<String, String> updatedPhotos = Map<String, String>.from(
|
||||
state.photoUrls,
|
||||
);
|
||||
if (item.photoUrl != null) {
|
||||
updatedPhotos[item.id] = item.photoUrl!;
|
||||
}
|
||||
|
||||
emit(state.copyWith(options: updatedOptions, photoUrls: updatedPhotos));
|
||||
}
|
||||
|
||||
Future<void> save() async {
|
||||
|
||||
@@ -9,12 +9,14 @@ class AttireState extends Equatable {
|
||||
this.options = const <AttireItem>[],
|
||||
this.selectedIds = const <String>[],
|
||||
this.photoUrls = const <String, String>{},
|
||||
this.filter = 'All',
|
||||
this.errorMessage,
|
||||
});
|
||||
final AttireStatus status;
|
||||
final List<AttireItem> options;
|
||||
final List<String> selectedIds;
|
||||
final Map<String, String> 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<AttireItem> 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<AttireItem>? options,
|
||||
List<String>? selectedIds,
|
||||
Map<String, String>? 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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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<AttireCaptureState>
|
||||
emit(state.copyWith(isAttested: value));
|
||||
}
|
||||
|
||||
Future<void> uploadPhoto(String itemId) async {
|
||||
Future<void> 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(
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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<AttireCapturePage> {
|
||||
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<void> _onGallery(BuildContext context) async {
|
||||
final AttireCaptureCubit cubit = BlocProvider.of<AttireCaptureCubit>(
|
||||
context,
|
||||
);
|
||||
if (!cubit.state.isAttested) {
|
||||
|
||||
// Skip attestation check if we already have a verification status
|
||||
if (!_hasVerificationStatus && !cubit.state.isAttested) {
|
||||
_showAttestationWarning(context);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final GalleryService service = Modular.get<GalleryService>();
|
||||
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<void> _onCamera(BuildContext context) async {
|
||||
final AttireCaptureCubit cubit = BlocProvider.of<AttireCaptureCubit>(
|
||||
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<CameraService>();
|
||||
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: <Widget>[
|
||||
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),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Call the upload via cubit
|
||||
cubit.uploadPhoto(widget.item.id);
|
||||
|
||||
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<void> _onSubmit(BuildContext context) async {
|
||||
final AttireCaptureCubit cubit = BlocProvider.of<AttireCaptureCubit>(
|
||||
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<AttireCapturePage> {
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: UiAppBar(title: widget.item.label, showBackButton: true),
|
||||
appBar: UiAppBar(
|
||||
title: widget.item.label,
|
||||
onLeadingPressed: () {
|
||||
Modular.to.toAttire();
|
||||
},
|
||||
),
|
||||
body: BlocConsumer<AttireCaptureCubit, AttireCaptureState>(
|
||||
bloc: cubit,
|
||||
listener: (BuildContext context, AttireCaptureState state) {
|
||||
@@ -66,35 +203,21 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
|
||||
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: <Widget>[
|
||||
Expanded(
|
||||
@@ -102,97 +225,38 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
|
||||
padding: const EdgeInsets.all(UiConstants.space5),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
// Image Preview (Toggle between example and uploaded)
|
||||
if (hasUploadedPhoto) ...<Widget>[
|
||||
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,
|
||||
ImagePreviewSection(
|
||||
selectedLocalPath: _selectedLocalPath,
|
||||
currentPhotoUrl: currentPhotoUrl,
|
||||
referenceImageUrl: widget.item.imageUrl,
|
||||
),
|
||||
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 ...<Widget>[
|
||||
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,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
|
||||
AttestationCheckbox(
|
||||
isChecked: state.isAttested,
|
||||
onChanged: (bool? val) {
|
||||
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),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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<AttirePage> createState() => _AttirePageState();
|
||||
}
|
||||
|
||||
class _AttirePageState extends State<AttirePage> {
|
||||
String _filter = 'All';
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final AttireCubit cubit = Modular.get<AttireCubit>();
|
||||
@@ -30,6 +23,7 @@ class _AttirePageState extends State<AttirePage> {
|
||||
appBar: UiAppBar(
|
||||
title: t.staff_profile_attire.title,
|
||||
showBackButton: true,
|
||||
onLeadingPressed: () => Modular.to.toProfile(),
|
||||
),
|
||||
body: BlocProvider<AttireCubit>.value(
|
||||
value: cubit,
|
||||
@@ -48,14 +42,7 @@ class _AttirePageState extends State<AttirePage> {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
final List<AttireItem> options = state.options;
|
||||
final List<AttireItem> filteredOptions = options.where((
|
||||
AttireItem item,
|
||||
) {
|
||||
if (_filter == 'Required') return item.isMandatory;
|
||||
if (_filter == 'Non-Essential') return !item.isMandatory;
|
||||
return true;
|
||||
}).toList();
|
||||
final List<AttireItem> filteredOptions = state.filteredOptions;
|
||||
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
@@ -70,12 +57,8 @@ class _AttirePageState extends State<AttirePage> {
|
||||
|
||||
// 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<AttirePage> {
|
||||
item: item,
|
||||
isUploading: false,
|
||||
uploadedPhotoUrl: state.photoUrls[item.id],
|
||||
onTap: () async {
|
||||
final String? resultUrl =
|
||||
await Navigator.push<String?>(
|
||||
context,
|
||||
MaterialPageRoute<String?>(
|
||||
builder: (BuildContext ctx) =>
|
||||
AttireCapturePage(
|
||||
onTap: () {
|
||||
Modular.to.toAttireCapture(
|
||||
item: item,
|
||||
initialPhotoUrl:
|
||||
state.photoUrls[item.id],
|
||||
),
|
||||
),
|
||||
initialPhotoUrl: state.photoUrls[item.id],
|
||||
);
|
||||
|
||||
if (resultUrl != null && mounted) {
|
||||
cubit.syncCapturedPhoto(item.id, resultUrl);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<void>(
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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: <Widget>[
|
||||
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: <Widget>[
|
||||
AttireUploadButtons(onGallery: onGallery, onCamera: onCamera),
|
||||
if (hasUploadedPhoto) ...<Widget>[
|
||||
const SizedBox(height: UiConstants.space4),
|
||||
UiButton.primary(
|
||||
fullWidth: true,
|
||||
text: 'Submit Image',
|
||||
onPressed: () {
|
||||
if (updatedItem != null) {
|
||||
Modular.to.pop(updatedItem);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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: <Widget>[
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
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: <Widget>[
|
||||
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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<bool?> onAttestationChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
if (description != null)
|
||||
Text(
|
||||
description!,
|
||||
style: UiTypography.body1r.textSecondary,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space8),
|
||||
|
||||
// Pending Banner
|
||||
if (isPending) ...<Widget>[
|
||||
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) ...<Widget>[
|
||||
AttestationCheckbox(
|
||||
isChecked: isAttested,
|
||||
onChanged: onAttestationChanged,
|
||||
),
|
||||
const SizedBox(height: UiConstants.space6),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -27,6 +27,7 @@ dependencies:
|
||||
path: ../../../../../design_system
|
||||
core_localization:
|
||||
path: ../../../../../core_localization
|
||||
image_picker: ^1.2.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"]) {
|
||||
|
||||
245
docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md
Normal file
245
docs/MILESTONES/M4/planning/m4-core-api-frontend-guide.md
Normal file
@@ -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 <firebase-id-token>
|
||||
```
|
||||
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/<uid>/173...",
|
||||
"contentType": "application/pdf",
|
||||
"size": 12345,
|
||||
"bucket": "krow-workforce-dev-private",
|
||||
"path": "uploads/<uid>/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/<uid>/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/<caller_uid>/...`)
|
||||
- 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": "<worker-id>",
|
||||
"fileUri": "gs://krow-workforce-dev-private/uploads/<uid>/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": "<uid>"
|
||||
},
|
||||
"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/<uid>/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`.
|
||||
Reference in New Issue
Block a user