feat: Implement dedicated attire capture page, refactor attire selection with item cards and filtering.

This commit is contained in:
Achintha Isuru
2026-02-24 15:51:28 -05:00
parent f8c9cd625f
commit 54a8915fb6
3 changed files with 484 additions and 45 deletions

View File

@@ -0,0 +1,245 @@
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:krow_domain/krow_domain.dart';
import 'package:flutter_modular/flutter_modular.dart';
import 'package:core_localization/core_localization.dart';
import '../blocs/attire_cubit.dart';
import '../blocs/attire_state.dart';
import '../widgets/attestation_checkbox.dart';
class AttireCapturePage extends StatefulWidget {
const AttireCapturePage({super.key, required this.item});
final AttireItem item;
@override
State<AttireCapturePage> createState() => _AttireCapturePageState();
}
class _AttireCapturePageState extends State<AttireCapturePage> {
bool _isAttested = false;
void _onUpload(BuildContext context) {
if (!_isAttested) {
UiSnackbar.show(
context,
message: 'Please attest that you own this item.',
type: UiSnackbarType.error,
margin: const EdgeInsets.all(UiConstants.space4),
);
return;
}
// Call the upload via cubit
final AttireCubit cubit = Modular.get<AttireCubit>();
cubit.uploadPhoto(widget.item.id);
}
void _viewEnlargedImage(BuildContext context) {
showDialog<void>(
context: context,
builder: (BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
child: Container(
constraints: const BoxConstraints(maxHeight: 500, maxWidth: 500),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
image: DecorationImage(
image: NetworkImage(
widget.item.imageUrl ??
'https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400',
),
fit: BoxFit.contain,
),
),
),
);
},
);
}
@override
Widget build(BuildContext context) {
final AttireCubit cubit = Modular.get<AttireCubit>();
return Scaffold(
backgroundColor: UiColors.background,
appBar: UiAppBar(title: widget.item.label, showBackButton: true),
body: BlocConsumer<AttireCubit, AttireState>(
bloc: cubit,
listener: (BuildContext context, AttireState state) {
if (state.status == AttireStatus.failure) {
UiSnackbar.show(
context,
message: translateErrorKey(state.errorMessage ?? 'Error'),
type: UiSnackbarType.error,
);
}
},
builder: (BuildContext context, AttireState state) {
final bool isUploading =
state.uploadingStatus[widget.item.id] ?? false;
final bool hasPhoto = state.photoUrls.containsKey(widget.item.id);
final String statusText = hasPhoto
? 'Pending Verification'
: 'Not Uploaded';
final Color statusColor = hasPhoto
? UiColors.textWarning
: UiColors.textInactive;
return Column(
children: <Widget>[
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
children: <Widget>[
// Image Preview
GestureDetector(
onTap: () => _viewEnlargedImage(context),
child: Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(
UiConstants.radiusBase,
),
boxShadow: const <BoxShadow>[
BoxShadow(
color: Color(0x19000000),
blurRadius: 4,
offset: Offset(0, 2),
),
],
image: DecorationImage(
image: NetworkImage(
widget.item.imageUrl ??
'https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400',
),
fit: BoxFit.cover,
),
),
child: const Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: EdgeInsets.all(8.0),
child: Icon(
UiIcons.search,
color: UiColors.white,
shadows: <Shadow>[
Shadow(color: Colors.black, blurRadius: 4),
],
),
),
),
),
),
const SizedBox(height: UiConstants.space6),
Text(
widget.item.description ?? '',
style: UiTypography.body1r.textSecondary,
textAlign: TextAlign.center,
),
const SizedBox(height: UiConstants.space6),
// Verification info
Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.bgPopup,
borderRadius: BorderRadius.circular(
UiConstants.radiusBase,
),
border: Border.all(color: UiColors.border),
),
child: Row(
children: <Widget>[
const Icon(
UiIcons.info,
color: UiColors.primary,
size: 24,
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(
'Verification Status',
style: UiTypography.footnote2m.textPrimary,
),
Text(
statusText,
style: UiTypography.body2m.copyWith(
color: statusColor,
),
),
],
),
),
],
),
),
const SizedBox(height: UiConstants.space6),
AttestationCheckbox(
isChecked: _isAttested,
onChanged: (bool? val) {
setState(() {
_isAttested = val ?? false;
});
},
),
const SizedBox(height: UiConstants.space6),
if (isUploading)
const Center(child: CircularProgressIndicator())
else if (!hasPhoto ||
true) // Show options even if has photo (allows re-upload)
Row(
children: <Widget>[
Expanded(
child: UiButton.secondary(
text: 'Gallery',
onPressed: () => _onUpload(context),
),
),
const SizedBox(width: UiConstants.space4),
Expanded(
child: UiButton.primary(
text: 'Camera',
onPressed: () => _onUpload(context),
),
),
],
),
],
),
),
),
if (hasPhoto)
SafeArea(
child: Padding(
padding: const EdgeInsets.all(UiConstants.space5),
child: SizedBox(
width: double.infinity,
child: UiButton.primary(
text: 'Submit Image',
onPressed: () {
Modular.to.pop();
},
),
),
),
),
],
);
},
),
);
}
}

View File

@@ -6,23 +6,54 @@ import 'package:core_localization/core_localization.dart';
import '../blocs/attire_cubit.dart';
import '../blocs/attire_state.dart';
import '../widgets/attestation_checkbox.dart';
import '../widgets/attire_bottom_bar.dart';
import '../widgets/attire_grid.dart';
import '../widgets/attire_info_card.dart';
import '../widgets/attire_item_card.dart';
import 'attire_capture_page.dart';
import 'package:krow_domain/krow_domain.dart';
class AttirePage extends StatelessWidget {
class AttirePage extends StatefulWidget {
const AttirePage({super.key});
@override
State<AttirePage> createState() => _AttirePageState();
}
class _AttirePageState extends State<AttirePage> {
String _filter = 'All';
Widget _buildFilterChip(String label) {
final bool isSelected = _filter == label;
return GestureDetector(
onTap: () => setState(() => _filter = label),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space2,
),
decoration: BoxDecoration(
color: isSelected ? UiColors.primary : UiColors.white,
borderRadius: UiConstants.radiusFull,
border: Border.all(
color: isSelected ? UiColors.primary : UiColors.border,
),
),
child: Text(
label,
textAlign: TextAlign.center,
style: (isSelected
? UiTypography.footnote2m.white
: UiTypography.footnote2m.textSecondary),
),
),
);
}
@override
Widget build(BuildContext context) {
// Note: t.staff_profile_attire is available via re-export of core_localization
final AttireCubit cubit = Modular.get<AttireCubit>();
return BlocProvider<AttireCubit>.value(
value: cubit,
child: Scaffold(
backgroundColor: UiColors.background, // FAFBFC
return Scaffold(
backgroundColor: UiColors.background,
appBar: UiAppBar(
title: t.staff_profile_attire.title,
showBackButton: true,
@@ -31,64 +62,86 @@ class AttirePage extends StatelessWidget {
child: Container(color: UiColors.border, height: 1.0),
),
),
body: BlocConsumer<AttireCubit, AttireState>(
body: BlocProvider<AttireCubit>.value(
value: cubit,
child: BlocConsumer<AttireCubit, AttireState>(
listener: (BuildContext context, AttireState state) {
if (state.status == AttireStatus.failure) {
UiSnackbar.show(
context,
message: translateErrorKey(state.errorMessage ?? 'Error'),
type: UiSnackbarType.error,
margin: const EdgeInsets.only(
bottom: 150,
left: UiConstants.space4,
right: UiConstants.space4,
),
);
}
if (state.status == AttireStatus.saved) {
Modular.to.pop();
}
},
builder: (BuildContext context, AttireState state) {
if (state.status == AttireStatus.loading && state.options.isEmpty) {
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();
return Column(
children: <Widget>[
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const AttireInfoCard(),
const SizedBox(height: UiConstants.space6),
AttireGrid(
items: state.options,
selectedIds: state.selectedIds,
photoUrls: state.photoUrls,
uploadingStatus: state.uploadingStatus,
onToggle: cubit.toggleSelection,
onUpload: cubit.uploadPhoto,
// Filter Chips
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: <Widget>[
_buildFilterChip('All'),
const SizedBox(width: UiConstants.space2),
_buildFilterChip('Required'),
const SizedBox(width: UiConstants.space2),
_buildFilterChip('Non-Essential'),
],
),
),
const SizedBox(height: UiConstants.space6),
AttestationCheckbox(
isChecked: state.attestationChecked,
onChanged: (bool? val) =>
cubit.toggleAttestation(val ?? false),
// Item List
...filteredOptions.map((AttireItem item) {
return Padding(
padding: const EdgeInsets.only(
bottom: UiConstants.space3,
),
child: AttireItemCard(
item: item,
isUploading:
state.uploadingStatus[item.id] ?? false,
uploadedPhotoUrl: state.photoUrls[item.id],
onTap: () {
Navigator.push<void>(
context,
MaterialPageRoute<void>(
builder: (BuildContext ctx) =>
AttireCapturePage(item: item),
),
);
},
),
);
}).toList(),
const SizedBox(height: UiConstants.space20),
],
),
),
),
AttireBottomBar(
canSave: state.canSave,
allMandatorySelected: state.allMandatorySelected,
allMandatoryHavePhotos: state.allMandatoryHavePhotos,
attestationChecked: state.attestationChecked,
onSave: cubit.save,
),
],
);
},

View File

@@ -0,0 +1,141 @@
import 'package:design_system/design_system.dart';
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,
this.uploadedPhotoUrl,
this.isUploading = false,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final bool hasPhoto = uploadedPhotoUrl != null;
final String statusText = hasPhoto ? 'Pending' : 'Not Uploaded';
final Color statusColor = hasPhoto
? UiColors.textWarning
: UiColors.textInactive;
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(UiConstants.space4),
decoration: BoxDecoration(
color: UiColors.white,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(color: UiColors.border),
boxShadow: const <BoxShadow>[
BoxShadow(
color: Color(0x19000000),
blurRadius: 4,
offset: Offset(0, 2),
),
],
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
// Image
Container(
width: 64,
height: 64,
decoration: BoxDecoration(
color: UiColors.background,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
image: DecorationImage(
image: NetworkImage(
item.imageUrl ??
'https://images.unsplash.com/photo-1549298916-b41d501d3772?auto=format&fit=crop&q=80&w=400&h=400',
),
fit: BoxFit.cover,
),
),
),
const SizedBox(width: UiConstants.space4),
// details
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Text(item.label, style: UiTypography.body1m.textPrimary),
if (item.description != null) ...<Widget>[
const SizedBox(height: UiConstants.space1),
Text(
item.description!,
style: UiTypography.body2r.textSecondary,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
const SizedBox(height: UiConstants.space2),
Row(
children: <Widget>[
if (item.isMandatory)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: UiColors.error.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'Required',
style: UiTypography.footnote2m.textError,
),
),
const Spacer(),
if (isUploading)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
else if (hasPhoto)
Text(
statusText,
style: UiTypography.footnote2m.copyWith(
color: statusColor,
),
),
],
),
],
),
),
const SizedBox(width: UiConstants.space2),
// Chevron or status
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const SizedBox(height: 20),
if (!hasPhoto && !isUploading)
const Icon(
UiIcons.chevronRight,
color: UiColors.textInactive,
size: 24,
)
else if (hasPhoto && !isUploading)
const Icon(
UiIcons.check,
color: UiColors.textWarning,
size: 24,
),
],
),
],
),
),
);
}
}