feat: Enable users to upload attire photos via camera or gallery.

This commit is contained in:
Achintha Isuru
2026-02-25 12:58:30 -05:00
parent 19b82ff73a
commit ed2b4f0563
9 changed files with 112 additions and 31 deletions

View File

@@ -12,6 +12,7 @@ import 'package:staff_authentication/staff_authentication.dart'
import 'package:staff_main/staff_main.dart' as staff_main; import 'package:staff_main/staff_main.dart' as staff_main;
import 'package:krow_core/core.dart'; import 'package:krow_core/core.dart';
import 'package:image_picker/image_picker.dart';
import 'src/widgets/session_listener.dart'; import 'src/widgets/session_listener.dart';
void main() async { void main() async {
@@ -26,7 +27,10 @@ void main() async {
// Initialize session listener for Firebase Auth state changes // Initialize session listener for Firebase Auth state changes
DataConnectService.instance.initializeAuthListener( 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( runApp(
@@ -40,8 +44,19 @@ void main() async {
/// The main application module. /// The main application module.
class AppModule extends Module { class AppModule extends Module {
@override @override
List<Module> get imports => void binds(Injector i) {
<Module>[ i.addLazySingleton<ImagePicker>(ImagePicker.new);
i.addLazySingleton<CameraService>(
() => CameraService(i.get<ImagePicker>()),
);
i.addLazySingleton<GalleryService>(
() => GalleryService(i.get<ImagePicker>()),
);
i.addLazySingleton<FilePickerService>(FilePickerService.new);
}
@override
List<Module> get imports => <Module>[
core_localization.LocalizationModule(), core_localization.LocalizationModule(),
staff_authentication.StaffAuthenticationModule(), staff_authentication.StaffAuthenticationModule(),
]; ];

View File

@@ -4,7 +4,7 @@ import 'package:krow_domain/krow_domain.dart';
/// Service for capturing photos and videos using the device camera. /// Service for capturing photos and videos using the device camera.
class CameraService extends BaseDeviceService { class CameraService extends BaseDeviceService {
/// Creates a [CameraService]. /// Creates a [CameraService].
CameraService(this._picker); CameraService(ImagePicker picker) : _picker = picker;
final ImagePicker _picker; final ImagePicker _picker;

View File

@@ -31,7 +31,7 @@ class AttireRepositoryImpl implements AttireRepository {
} }
@override @override
Future<String> uploadPhoto(String itemId) async { Future<String> uploadPhoto(String itemId, String filePath) async {
// In a real app, this would upload to Firebase Storage first. // 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. // Since the prototype returns a mock URL, we'll use that to upsert our record.
final String mockUrl = 'mock_url_for_$itemId'; final String mockUrl = 'mock_url_for_$itemId';

View File

@@ -7,10 +7,17 @@ class UploadAttirePhotoArguments extends UseCaseArgument {
// We'll stick to that signature for now to "preserve behavior". // We'll stick to that signature for now to "preserve behavior".
/// Creates a [UploadAttirePhotoArguments]. /// Creates a [UploadAttirePhotoArguments].
const UploadAttirePhotoArguments({required this.itemId}); const UploadAttirePhotoArguments({
required this.itemId,
required this.filePath,
});
/// The ID of the attire item being uploaded. /// The ID of the attire item being uploaded.
final String itemId; final String itemId;
/// The local path to the photo file.
final String filePath;
@override @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. /// Fetches the list of available attire options.
Future<List<AttireItem>> getAttireOptions(); Future<List<AttireItem>> getAttireOptions();
/// Simulates uploading a photo for a specific attire item. /// Uploads a photo for a specific attire item.
Future<String> uploadPhoto(String itemId); Future<String> uploadPhoto(String itemId, String filePath);
/// Saves the user's attire selection and attestations. /// Saves the user's attire selection and attestations.
Future<void> saveAttire({ Future<void> saveAttire({

View File

@@ -3,14 +3,14 @@ import '../arguments/upload_attire_photo_arguments.dart';
import '../repositories/attire_repository.dart'; import '../repositories/attire_repository.dart';
/// Use case to upload a photo for an attire item. /// Use case to upload a photo for an attire item.
class UploadAttirePhotoUseCase extends UseCase<UploadAttirePhotoArguments, String> { class UploadAttirePhotoUseCase
extends UseCase<UploadAttirePhotoArguments, String> {
/// Creates a [UploadAttirePhotoUseCase]. /// Creates a [UploadAttirePhotoUseCase].
UploadAttirePhotoUseCase(this._repository); UploadAttirePhotoUseCase(this._repository);
final AttireRepository _repository; final AttireRepository _repository;
@override @override
Future<String> call(UploadAttirePhotoArguments arguments) { Future<String> call(UploadAttirePhotoArguments arguments) {
return _repository.uploadPhoto(arguments.itemId); return _repository.uploadPhoto(arguments.itemId, arguments.filePath);
} }
} }

View File

@@ -16,14 +16,14 @@ class AttireCaptureCubit extends Cubit<AttireCaptureState>
emit(state.copyWith(isAttested: value)); 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)); emit(state.copyWith(status: AttireCaptureStatus.uploading));
await handleError( await handleError(
emit: emit, emit: emit,
action: () async { action: () async {
final String url = await _uploadAttirePhotoUseCase( final String url = await _uploadAttirePhotoUseCase(
UploadAttirePhotoArguments(itemId: itemId), UploadAttirePhotoArguments(itemId: itemId, filePath: filePath),
); );
emit( emit(

View File

@@ -3,6 +3,7 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:krow_core/core.dart';
import 'package:krow_domain/krow_domain.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_cubit.dart';
import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_state.dart'; import 'package:staff_attire/src/presentation/blocs/attire_capture/attire_capture_state.dart';
@@ -27,21 +28,71 @@ class AttireCapturePage extends StatefulWidget {
} }
class _AttireCapturePageState extends State<AttireCapturePage> { class _AttireCapturePageState extends State<AttireCapturePage> {
void _onUpload(BuildContext context) { /// On gallery button press
Future<void> _onGallery(BuildContext context) async {
final AttireCaptureCubit cubit = BlocProvider.of<AttireCaptureCubit>( final AttireCaptureCubit cubit = BlocProvider.of<AttireCaptureCubit>(
context, context,
); );
if (!cubit.state.isAttested) { if (!cubit.state.isAttested) {
_showAttestationWarning(context);
return;
}
try {
final GalleryService service = Modular.get<GalleryService>();
final String? path = await service.pickImage();
if (path != null && context.mounted) {
await cubit.uploadPhoto(widget.item.id, 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,
);
if (!cubit.state.isAttested) {
_showAttestationWarning(context);
return;
}
try {
final CameraService service = Modular.get<CameraService>();
final String? path = await service.takePhoto();
if (path != null && context.mounted) {
await cubit.uploadPhoto(widget.item.id, path);
}
} catch (e) {
if (context.mounted) {
_showError(context, 'Could not access camera: $e');
}
}
}
void _showAttestationWarning(BuildContext context) {
UiSnackbar.show( UiSnackbar.show(
context, context,
message: 'Please attest that you own this item.', message: 'Please attest that you own this item.',
type: UiSnackbarType.error, type: UiSnackbarType.error,
margin: const EdgeInsets.all(UiConstants.space4), margin: const EdgeInsets.all(UiConstants.space4),
); );
return;
} }
// Call the upload via cubit
cubit.uploadPhoto(widget.item.id); void _showError(BuildContext context, String message) {
debugPrint(message);
UiSnackbar.show(
context,
message: 'Could not access camera or gallery. Please try again.',
type: UiSnackbarType.error,
margin: const EdgeInsets.all(UiConstants.space4),
);
} }
@override @override
@@ -174,7 +225,10 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
), ),
) )
else else
AttireUploadButtons(onUpload: _onUpload), AttireUploadButtons(
onGallery: () => _onGallery(context),
onCamera: () => _onCamera(context),
),
], ],
), ),
), ),

View File

@@ -2,9 +2,14 @@ import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class AttireUploadButtons extends StatelessWidget { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -14,7 +19,7 @@ class AttireUploadButtons extends StatelessWidget {
child: UiButton.secondary( child: UiButton.secondary(
leadingIcon: UiIcons.gallery, leadingIcon: UiIcons.gallery,
text: 'Gallery', text: 'Gallery',
onPressed: () => onUpload(context), onPressed: onGallery,
), ),
), ),
const SizedBox(width: UiConstants.space4), const SizedBox(width: UiConstants.space4),
@@ -22,7 +27,7 @@ class AttireUploadButtons extends StatelessWidget {
child: UiButton.primary( child: UiButton.primary(
leadingIcon: UiIcons.camera, leadingIcon: UiIcons.camera,
text: 'Camera', text: 'Camera',
onPressed: () => onUpload(context), onPressed: onCamera,
), ),
), ),
], ],