refactor: Decompose AttireCapturePage into dedicated widgets for info, image preview, and footer sections, and refine attestation and verification status logic.

This commit is contained in:
Achintha Isuru
2026-02-25 19:21:45 -05:00
parent 4515d42cd3
commit e0722c938d
4 changed files with 369 additions and 137 deletions

View File

@@ -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<AttireCapturePage> {
String? _selectedLocalPath;
/// Whether a verification status is already present for this item.
bool get _hasVerificationStatus => widget.item.verificationStatus != null;
/// Whether the item is currently pending verification.
bool get _isPending =>
widget.item.verificationStatus == AttireVerificationStatus.pending;
/// On gallery button press
Future<void> _onGallery(BuildContext context) async {
final AttireCaptureCubit cubit = BlocProvider.of<AttireCaptureCubit>(
context,
);
if (!cubit.state.isAttested) {
// Skip attestation check if we already have a verification status
if (!_hasVerificationStatus && !cubit.state.isAttested) {
_showAttestationWarning(context);
return;
}
@@ -62,7 +74,8 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
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<AttireCapturePage> {
}
}
/// 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,
@@ -119,6 +162,7 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
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<AttireCapturePage> {
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<AttireCapturePage> {
}
},
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: <Widget>[
Expanded(
@@ -172,139 +211,38 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
children: <Widget>[
// Image Preview (Toggle between example, review, and uploaded)
if (_selectedLocalPath != null) ...<Widget>[
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,
ImagePreviewSection(
selectedLocalPath: _selectedLocalPath,
currentPhotoUrl: currentPhotoUrl,
referenceImageUrl: widget.item.imageUrl,
),
const SizedBox(height: UiConstants.space1),
Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(
UiConstants.radiusBase,
),
child: Image.network(
widget.item.imageUrl ?? '',
height: 120,
fit: BoxFit.cover,
errorBuilder: (_, _, _) =>
const SizedBox.shrink(),
),
),
),
] else 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.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,
),
const SizedBox(height: UiConstants.space6),
AttestationCheckbox(
isChecked: state.isAttested,
onChanged: (bool? val) {
InfoSection(
description: widget.item.description,
statusText: _getStatusText(hasUploadedPhoto),
statusColor: _getStatusColor(hasUploadedPhoto),
isPending: _isPending,
showCheckbox: !_hasVerificationStatus,
isAttested: state.isAttested,
onAttestationChanged: (bool? val) {
cubit.toggleAttestation(val ?? false);
},
),
const SizedBox(height: UiConstants.space6),
],
),
),
),
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 ...<Widget>[
AttireUploadButtons(
FooterSection(
isUploading:
state.status == AttireCaptureStatus.uploading,
selectedLocalPath: _selectedLocalPath,
hasVerificationStatus: _hasVerificationStatus,
hasUploadedPhoto: hasUploadedPhoto,
updatedItem: state.updatedItem,
onGallery: () => _onGallery(context),
onCamera: () => _onCamera(context),
),
if (_selectedLocalPath != null) ...<Widget>[
const SizedBox(height: UiConstants.space4),
UiButton.primary(
fullWidth: true,
text: 'Submit Image',
onPressed: () => _onSubmit(context),
),
] else if (hasUploadedPhoto) ...<Widget>[
const SizedBox(height: UiConstants.space4),
UiButton.primary(
fullWidth: true,
text: 'Submit Image',
onPressed: () {
Modular.to.pop(state.updatedItem);
},
),
],
],
],
),
),
onSubmit: () => _onSubmit(context),
onReupload: () => _onReupload(context),
),
],
);

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