feat: Implement dedicated attire capture page, refactor attire selection with item cards and filtering.
This commit is contained in:
@@ -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();
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,89 +6,142 @@ 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
|
||||
Widget build(BuildContext context) {
|
||||
// Note: t.staff_profile_attire is available via re-export of core_localization
|
||||
final AttireCubit cubit = Modular.get<AttireCubit>();
|
||||
State<AttirePage> createState() => _AttirePageState();
|
||||
}
|
||||
|
||||
return BlocProvider<AttireCubit>.value(
|
||||
value: cubit,
|
||||
child: Scaffold(
|
||||
backgroundColor: UiColors.background, // FAFBFC
|
||||
appBar: UiAppBar(
|
||||
title: t.staff_profile_attire.title,
|
||||
showBackButton: true,
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(1.0),
|
||||
child: Container(color: UiColors.border, height: 1.0),
|
||||
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,
|
||||
),
|
||||
),
|
||||
body: BlocConsumer<AttireCubit, AttireState>(
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: (isSelected
|
||||
? UiTypography.footnote2m.white
|
||||
: UiTypography.footnote2m.textSecondary),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final AttireCubit cubit = Modular.get<AttireCubit>();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: UiColors.background,
|
||||
appBar: UiAppBar(
|
||||
title: t.staff_profile_attire.title,
|
||||
showBackButton: true,
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(1.0),
|
||||
child: Container(color: UiColors.border, height: 1.0),
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user