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:
Achintha Isuru
2026-02-25 23:10:25 -05:00
committed by GitHub
77 changed files with 2448 additions and 353 deletions

1
.gitignore vendored
View File

@@ -43,6 +43,7 @@ lerna-debug.log*
*.temp
tmp/
temp/
scripts/issues-to-create.md
# ==============================================================================
# SECURITY (CRITICAL)

View File

@@ -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) {

View File

@@ -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"]];
}

View File

@@ -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);

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
url_launcher_linux
)

View File

@@ -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"))

View File

@@ -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(

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
firebase_auth
firebase_core
url_launcher_windows

View File

@@ -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) {

View File

@@ -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"]];

View File

@@ -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,11 +64,11 @@ void main() async {
/// The main application module.
class AppModule extends Module {
@override
List<Module> get imports =>
<Module>[
core_localization.LocalizationModule(),
staff_authentication.StaffAuthenticationModule(),
];
List<Module> get imports => <Module>[
CoreModule(),
core_localization.LocalizationModule(),
staff_authentication.StaffAuthenticationModule(),
];
@override
void routes(RouteManager r) {

View File

@@ -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);

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
url_launcher_linux
)

View File

@@ -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"))

View File

@@ -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(

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
firebase_auth
firebase_core
geolocator_windows

View File

@@ -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"
}

View File

@@ -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';

View File

@@ -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',
);
}

View 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>(),
),
);
}
}

View File

@@ -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,
},
);
}
// ==========================================================================

View File

@@ -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
// ==========================================================================

View File

@@ -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>{};
}
}

View File

@@ -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';
}

View File

@@ -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,
};
}
}

View File

@@ -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);
}
}

View File

@@ -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,
};
}
}

View File

@@ -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);
}
}

View File

@@ -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,
};
}
}

View File

@@ -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);
}
}

View File

@@ -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,
};
}
}

View File

@@ -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);
}
}

View File

@@ -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
]);
}
}

View File

@@ -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);
}
}

View File

@@ -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;
});
}
}

View File

@@ -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;
});
}
}

View File

@@ -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,
);
});
}
}

View File

@@ -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;
});
}
}

View File

@@ -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

View File

@@ -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,
),
photoUrl: userAttire?.verificationPhotoUrl,
return optionsRes.data.attireOptions.map((
dc.ListAttireOptionsAttireOptions opt,
) {
final dc.GetStaffAttireStaffAttires currentAttire = staffAttire
.firstWhere(
(dc.GetStaffAttireStaffAttires a) => a.attireOptionId == opt.id,
orElse: () => dc.GetStaffAttireStaffAttires(
attireOptionId: opt.id,
verificationPhotoUrl: null,
verificationId: null,
verificationStatus: null,
),
);
return domain.AttireItem(
id: opt.id,
code: opt.itemId,
label: opt.label,
description: opt.description,
imageUrl: opt.imageUrl,
isMandatory: opt.isMandatory ?? false,
photoUrl: currentAttire.verificationPhotoUrl,
verificationId: currentAttire.verificationId,
verificationStatus: currentAttire.verificationStatus != null
? _mapFromDCStatus(currentAttire.verificationStatus!)
: null,
);
}).toList();
});
}
AttireVerificationStatus? _mapAttireStatus(String? status) {
if (status == null) return null;
return AttireVerificationStatus.values.firstWhere(
(e) => e.name.toUpperCase() == status.toUpperCase(),
orElse: () => AttireVerificationStatus.pending,
);
}
@override
Future<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();
});
}

View File

@@ -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,
});
}

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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,
});
}

View File

@@ -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()},
);
}
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}
}

View File

@@ -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,
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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),

View File

@@ -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);
}

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@@ -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"))

View File

@@ -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(

View File

@@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
firebase_auth
firebase_core
)

View File

@@ -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?,
),
);
}
}

View File

@@ -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;
}
}
}

View File

@@ -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];
}

View File

@@ -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({

View File

@@ -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);
}
}

View File

@@ -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 {

View File

@@ -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,
];
}

View File

@@ -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(

View File

@@ -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,
];
}

View File

@@ -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) {
UiSnackbar.show(
context,
message: 'Please attest that you own this item.',
type: UiSnackbarType.error,
margin: const EdgeInsets.all(UiConstants.space4),
);
// Skip attestation check if we already have a verification status
if (!_hasVerificationStatus && !cubit.state.isAttested) {
_showAttestationWarning(context);
return;
}
// Call the upload via cubit
cubit.uploadPhoto(widget.item.id);
try {
final GalleryService service = Modular.get<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),
);
}
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,98 +225,39 @@ 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,
),
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,
ImagePreviewSection(
selectedLocalPath: _selectedLocalPath,
currentPhotoUrl: currentPhotoUrl,
referenceImageUrl: widget.item.imageUrl,
),
const SizedBox(height: UiConstants.space6),
AttestationCheckbox(
isChecked: state.isAttested,
onChanged: (bool? val) {
const SizedBox(height: UiConstants.space1),
InfoSection(
description: widget.item.description,
statusText: _getStatusText(hasUploadedPhoto),
statusColor: _getStatusColor(hasUploadedPhoto),
isPending: _isPending,
showCheckbox: !_hasVerificationStatus,
isAttested: state.isAttested,
onAttestationChanged: (bool? val) {
cubit.toggleAttestation(val ?? false);
},
),
const SizedBox(height: UiConstants.space6),
if (isUploading)
const Center(
child: Padding(
padding: EdgeInsets.all(UiConstants.space8),
child: CircularProgressIndicator(),
),
)
else
AttireUploadButtons(onUpload: _onUpload),
],
),
),
),
if (hasUploadedPhoto)
SafeArea(
child: Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: SizedBox(
width: double.infinity,
child: UiButton.primary(
text: 'Submit Image',
onPressed: () {
Modular.to.pop(currentPhotoUrl);
},
),
),
),
),
FooterSection(
isUploading:
state.status == AttireCaptureStatus.uploading,
selectedLocalPath: _selectedLocalPath,
hasVerificationStatus: _hasVerificationStatus,
hasUploadedPhoto: hasUploadedPhoto,
updatedItem: state.updatedItem,
onGallery: () => _onGallery(context),
onCamera: () => _onCamera(context),
onSubmit: () => _onSubmit(context),
onReupload: () => _onReupload(context),
),
],
);
},

View File

@@ -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(
item: item,
initialPhotoUrl:
state.photoUrls[item.id],
),
),
);
if (resultUrl != null && mounted) {
cubit.syncCapturedPhoto(item.id, resultUrl);
}
onTap: () {
Modular.to.toAttireCapture(
item: item,
initialPhotoUrl: state.photoUrls[item.id],
);
},
),
);

View File

@@ -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,

View File

@@ -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,
),
),
],

View File

@@ -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);
}
},
),
],
],
);
}
}

View File

@@ -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(),
),
),
),
],
);
}
}

View File

@@ -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),
],
],
);
}
}

View File

@@ -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,

View File

@@ -27,6 +27,7 @@ dependencies:
path: ../../../../../design_system
core_localization:
path: ../../../../../core_localization
image_picker: ^1.2.1
dev_dependencies:
flutter_test:

View File

@@ -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:

View File

@@ -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
}
)
}

View File

@@ -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"]) {

View 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`.