From e0722c938d037de37cf971a7c8baa3ad70d81193 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Wed, 25 Feb 2026 19:21:45 -0500 Subject: [PATCH] refactor: Decompose AttireCapturePage into dedicated widgets for info, image preview, and footer sections, and refine attestation and verification status logic. --- .../pages/attire_capture_page.dart | 212 +++++++----------- .../attire_capture_page/footer_section.dart | 109 +++++++++ .../image_preview_section.dart | 96 ++++++++ .../attire_capture_page/info_section.dart | 89 ++++++++ 4 files changed, 369 insertions(+), 137 deletions(-) create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/footer_section.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/image_preview_section.dart create mode 100644 apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/info_section.dart diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart index 1c3adbd8..1792f82f 100644 --- a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/pages/attire_capture_page.dart @@ -8,19 +8,23 @@ 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 @@ -30,13 +34,21 @@ class AttireCapturePage extends StatefulWidget { class _AttireCapturePageState extends State { String? _selectedLocalPath; + /// Whether a verification status is already present for this item. + bool get _hasVerificationStatus => widget.item.verificationStatus != null; + + /// Whether the item is currently pending verification. + bool get _isPending => + widget.item.verificationStatus == AttireVerificationStatus.pending; + /// On gallery button press Future _onGallery(BuildContext context) async { final AttireCaptureCubit cubit = BlocProvider.of( context, ); - if (!cubit.state.isAttested) { + // Skip attestation check if we already have a verification status + if (!_hasVerificationStatus && !cubit.state.isAttested) { _showAttestationWarning(context); return; } @@ -62,7 +74,8 @@ class _AttireCapturePageState extends State { context, ); - if (!cubit.state.isAttested) { + // Skip attestation check if we already have a verification status + if (!_hasVerificationStatus && !cubit.state.isAttested) { _showAttestationWarning(context); return; } @@ -82,6 +95,36 @@ class _AttireCapturePageState extends State { } } + /// Show a bottom sheet for reuploading options. + void _onReupload(BuildContext context) { + showModalBottomSheet( + context: context, + builder: (BuildContext sheetContext) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.photo_library), + title: const Text('Gallery'), + onTap: () { + Modular.to.pop(); + _onGallery(context); + }, + ), + ListTile( + leading: const Icon(Icons.camera_alt), + title: const Text('Camera'), + onTap: () { + Modular.to.pop(); + _onCamera(context); + }, + ), + ], + ), + ), + ); + } + void _showAttestationWarning(BuildContext context) { UiSnackbar.show( context, @@ -119,6 +162,7 @@ class _AttireCapturePageState extends State { return switch (widget.item.verificationStatus) { AttireVerificationStatus.approved => 'Approved', AttireVerificationStatus.rejected => 'Rejected', + AttireVerificationStatus.pending => 'Pending Verification', _ => hasUploadedPhoto ? 'Pending Verification' : 'Not Uploaded', }; } @@ -127,6 +171,7 @@ class _AttireCapturePageState extends State { return switch (widget.item.verificationStatus) { AttireVerificationStatus.approved => UiColors.textSuccess, AttireVerificationStatus.rejected => UiColors.textError, + AttireVerificationStatus.pending => UiColors.textWarning, _ => hasUploadedPhoto ? UiColors.textWarning : UiColors.textInactive, }; } @@ -155,16 +200,10 @@ class _AttireCapturePageState extends State { } }, 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 = _getStatusText(hasUploadedPhoto); - - final Color statusColor = _getStatusColor(hasUploadedPhoto); - return Column( children: [ Expanded( @@ -172,139 +211,38 @@ class _AttireCapturePageState extends State { padding: const EdgeInsets.all(UiConstants.space5), child: Column( children: [ - // Image Preview (Toggle between example, review, and uploaded) - if (_selectedLocalPath != null) ...[ - Text( - 'Review the attire item', - style: UiTypography.body1b.textPrimary, - ), - const SizedBox(height: UiConstants.space2), - AttireImagePreview(localPath: _selectedLocalPath), - 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 if (hasUploadedPhoto) ...[ - Text( - 'Your Uploaded Photo', - style: UiTypography.body1b.textPrimary, - ), - const SizedBox(height: UiConstants.space2), - AttireImagePreview(imageUrl: currentPhotoUrl), - const SizedBox(height: UiConstants.space4), - Text( - 'Reference Example', - style: UiTypography.body2b.textSecondary, - ), - const SizedBox(height: UiConstants.space1), - Center( - child: ClipRRect( - borderRadius: BorderRadius.circular( - UiConstants.radiusBase, - ), - child: Image.network( - widget.item.imageUrl ?? '', - height: 120, - fit: BoxFit.cover, - errorBuilder: (_, _, _) => - const SizedBox.shrink(), - ), - ), - ), - ] else ...[ - AttireImagePreview( - imageUrl: widget.item.imageUrl, - ), - const SizedBox(height: UiConstants.space4), - Text( - 'Example of the item that you need to upload.', - style: UiTypography.body1b.textSecondary, - textAlign: TextAlign.center, - ), - ], - - const SizedBox(height: UiConstants.space1), - 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), ], ), ), ), - SafeArea( - child: Padding( - padding: const EdgeInsets.all(UiConstants.space5), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - if (isUploading) - const Center( - child: Padding( - padding: EdgeInsets.all(UiConstants.space4), - child: CircularProgressIndicator(), - ), - ) - else ...[ - AttireUploadButtons( - onGallery: () => _onGallery(context), - onCamera: () => _onCamera(context), - ), - if (_selectedLocalPath != null) ...[ - const SizedBox(height: UiConstants.space4), - UiButton.primary( - fullWidth: true, - text: 'Submit Image', - onPressed: () => _onSubmit(context), - ), - ] else if (hasUploadedPhoto) ...[ - const SizedBox(height: UiConstants.space4), - UiButton.primary( - fullWidth: true, - text: 'Submit Image', - onPressed: () { - Modular.to.pop(state.updatedItem); - }, - ), - ], - ], - ], - ), - ), + FooterSection( + isUploading: + state.status == AttireCaptureStatus.uploading, + selectedLocalPath: _selectedLocalPath, + hasVerificationStatus: _hasVerificationStatus, + hasUploadedPhoto: hasUploadedPhoto, + updatedItem: state.updatedItem, + onGallery: () => _onGallery(context), + onCamera: () => _onCamera(context), + onSubmit: () => _onSubmit(context), + onReupload: () => _onReupload(context), ), ], ); diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/footer_section.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/footer_section.dart new file mode 100644 index 00000000..6f0b4c2e --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/footer_section.dart @@ -0,0 +1,109 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_modular/flutter_modular.dart'; +import 'package:krow_domain/krow_domain.dart'; + +import 'attire_upload_buttons.dart'; + +/// Handles the primary actions at the bottom of the page. +class FooterSection extends StatelessWidget { + /// Creates a [FooterSection]. + const FooterSection({ + super.key, + required this.isUploading, + this.selectedLocalPath, + required this.hasVerificationStatus, + required this.hasUploadedPhoto, + this.updatedItem, + required this.onGallery, + required this.onCamera, + required this.onSubmit, + required this.onReupload, + }); + + /// Whether a photo is currently being uploaded. + final bool isUploading; + + /// The local path of the selected photo. + final String? selectedLocalPath; + + /// Whether the item already has a verification status. + final bool hasVerificationStatus; + + /// Whether the item has an uploaded photo. + final bool hasUploadedPhoto; + + /// The updated attire item, if any. + final AttireItem? updatedItem; + + /// Callback to open the gallery. + final VoidCallback onGallery; + + /// Callback to open the camera. + final VoidCallback onCamera; + + /// Callback to submit the photo. + final VoidCallback onSubmit; + + /// Callback to trigger the re-upload flow. + final VoidCallback onReupload; + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(UiConstants.space5), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (isUploading) + const Center( + child: Padding( + padding: EdgeInsets.all(UiConstants.space4), + child: CircularProgressIndicator(), + ), + ) + else + _buildActionButtons(), + ], + ), + ), + ); + } + + Widget _buildActionButtons() { + if (selectedLocalPath != null) { + return UiButton.primary( + fullWidth: true, + text: 'Submit Image', + onPressed: onSubmit, + ); + } + + if (hasVerificationStatus) { + return UiButton.secondary( + fullWidth: true, + text: 'Re Upload', + onPressed: onReupload, + ); + } + + return Column( + children: [ + AttireUploadButtons(onGallery: onGallery, onCamera: onCamera), + if (hasUploadedPhoto) ...[ + const SizedBox(height: UiConstants.space4), + UiButton.primary( + fullWidth: true, + text: 'Submit Image', + onPressed: () { + if (updatedItem != null) { + Modular.to.pop(updatedItem); + } + }, + ), + ], + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/image_preview_section.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/image_preview_section.dart new file mode 100644 index 00000000..18a6e930 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/image_preview_section.dart @@ -0,0 +1,96 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import 'attire_image_preview.dart'; + +/// Displays the comparison between the reference example and the user's photo. +class ImagePreviewSection extends StatelessWidget { + /// Creates an [ImagePreviewSection]. + const ImagePreviewSection({ + super.key, + this.selectedLocalPath, + this.currentPhotoUrl, + this.referenceImageUrl, + }); + + /// The local file path of the selected image. + final String? selectedLocalPath; + + /// The URL of the currently uploaded photo. + final String? currentPhotoUrl; + + /// The URL of the reference example image. + final String? referenceImageUrl; + + @override + Widget build(BuildContext context) { + if (selectedLocalPath != null) { + return Column( + children: [ + Text( + 'Review the attire item', + style: UiTypography.body1b.textPrimary, + ), + const SizedBox(height: UiConstants.space2), + AttireImagePreview(localPath: selectedLocalPath), + const SizedBox(height: UiConstants.space4), + ReferenceExample(imageUrl: referenceImageUrl), + ], + ); + } + + if (currentPhotoUrl != null) { + return Column( + children: [ + Text('Your Uploaded Photo', style: UiTypography.body1b.textPrimary), + const SizedBox(height: UiConstants.space2), + AttireImagePreview(imageUrl: currentPhotoUrl), + const SizedBox(height: UiConstants.space4), + ReferenceExample(imageUrl: referenceImageUrl), + ], + ); + } + + return Column( + children: [ + AttireImagePreview(imageUrl: referenceImageUrl), + const SizedBox(height: UiConstants.space4), + Text( + 'Example of the item that you need to upload.', + style: UiTypography.body1b.textSecondary, + textAlign: TextAlign.center, + ), + ], + ); + } +} + +/// Displays the reference item photo as an example. +class ReferenceExample extends StatelessWidget { + /// Creates a [ReferenceExample]. + const ReferenceExample({super.key, this.imageUrl}); + + /// The URL of the image to display. + final String? imageUrl; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text('Reference Example', style: UiTypography.body2b.textSecondary), + const SizedBox(height: UiConstants.space1), + Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + child: Image.network( + imageUrl ?? '', + height: 120, + fit: BoxFit.cover, + errorBuilder: (_, _, _) => const SizedBox.shrink(), + ), + ), + ), + ], + ); + } +} diff --git a/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/info_section.dart b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/info_section.dart new file mode 100644 index 00000000..be5995f2 --- /dev/null +++ b/apps/mobile/packages/features/staff/profile_sections/onboarding/attire/lib/src/presentation/widgets/attire_capture_page/info_section.dart @@ -0,0 +1,89 @@ +import 'package:design_system/design_system.dart'; +import 'package:flutter/material.dart'; + +import '../attestation_checkbox.dart'; +import 'attire_verification_status_card.dart'; + +/// Displays the item details, verification status, and attestation checkbox. +class InfoSection extends StatelessWidget { + /// Creates an [InfoSection]. + const InfoSection({ + super.key, + this.description, + required this.statusText, + required this.statusColor, + required this.isPending, + required this.showCheckbox, + required this.isAttested, + required this.onAttestationChanged, + }); + + /// The description of the attire item. + final String? description; + + /// The text to display for the verification status. + final String statusText; + + /// The color to use for the verification status text. + final Color statusColor; + + /// Whether the item is currently pending verification. + final bool isPending; + + /// Whether to show the attestation checkbox. + final bool showCheckbox; + + /// Whether the user has attested to owning the item. + final bool isAttested; + + /// Callback when the attestation status changes. + final ValueChanged onAttestationChanged; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + if (description != null) + Text( + description!, + style: UiTypography.body1r.textSecondary, + textAlign: TextAlign.center, + ), + const SizedBox(height: UiConstants.space8), + + // Pending Banner + if (isPending) ...[ + Container( + width: double.infinity, + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.tagPending, + borderRadius: BorderRadius.circular(UiConstants.radiusBase), + ), + child: Text( + 'A Manager will Verify This Item', + style: UiTypography.body2b.textWarning, + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: UiConstants.space4), + ], + + // Verification info + AttireVerificationStatusCard( + statusText: statusText, + statusColor: statusColor, + ), + const SizedBox(height: UiConstants.space6), + + if (showCheckbox) ...[ + AttestationCheckbox( + isChecked: isAttested, + onChanged: onAttestationChanged, + ), + const SizedBox(height: UiConstants.space6), + ], + ], + ); + } +}