feat: localization, file restriction banners, test credentials, edit icon fix

- #553: Audit and verify localizations (en/es), replace hardcoded strings
- #549: Incomplete profile banner in Find Shifts (staff app)
- #550: File restriction banner on document upload page
- #551: File restriction banner on certificate upload page
- #552: File restriction banner on attire upload page
- #492: Hide edit icon for past/completed orders (client app)
- #524: Display worker benefits in staff app
- Add test credentials to seed: testclient@gmail.com, staff +1-555-555-1234
- Fix document upload validation (context arg in _validatePdfFile on submit)
- Add PR_LOCALIZATION.md

Made-with: Cursor
This commit is contained in:
2026-02-27 13:48:04 +05:30
parent 66ffce413a
commit 34afe09963
26 changed files with 865 additions and 132 deletions

View File

@@ -1,3 +1,5 @@
import 'dart:io';
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
@@ -31,6 +33,12 @@ class AttireCapturePage extends StatefulWidget {
State<AttireCapturePage> createState() => _AttireCapturePageState();
}
/// Maximum file size for attire upload (10MB).
const int _kMaxFileSizeBytes = 10 * 1024 * 1024;
/// Allowed file extensions for attire upload.
const Set<String> _kAllowedExtensions = <String>{'jpeg', 'jpg', 'png'};
class _AttireCapturePageState extends State<AttireCapturePage> {
String? _selectedLocalPath;
@@ -57,6 +65,16 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
final GalleryService service = Modular.get<GalleryService>();
final String? path = await service.pickImage();
if (path != null && context.mounted) {
final String? error = _validateFile(path);
if (error != null) {
UiSnackbar.show(
context,
message: error,
type: UiSnackbarType.error,
margin: const EdgeInsets.all(UiConstants.space4),
);
return;
}
setState(() {
_selectedLocalPath = path;
});
@@ -84,6 +102,16 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
final CameraService service = Modular.get<CameraService>();
final String? path = await service.takePhoto();
if (path != null && context.mounted) {
final String? error = _validateFile(path);
if (error != null) {
UiSnackbar.show(
context,
message: error,
type: UiSnackbarType.error,
margin: const EdgeInsets.all(UiConstants.space4),
);
return;
}
setState(() {
_selectedLocalPath = path;
});
@@ -105,7 +133,7 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
children: <Widget>[
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text('Gallery'),
title: Text(t.common.gallery),
onTap: () {
Modular.to.pop();
_onGallery(context);
@@ -113,7 +141,7 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
),
ListTile(
leading: const Icon(Icons.camera_alt),
title: const Text('Camera'),
title: Text(t.common.camera),
onTap: () {
Modular.to.pop();
_onCamera(context);
@@ -128,17 +156,33 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
void _showAttestationWarning(BuildContext context) {
UiSnackbar.show(
context,
message: 'Please attest that you own this item.',
message: t.staff_profile_attire.capture.attest_please,
type: UiSnackbarType.error,
margin: const EdgeInsets.all(UiConstants.space4),
);
}
/// Validates file format (JPEG, JPG, PNG) and size (max 10MB).
/// Returns an error message if invalid, or null if valid.
String? _validateFile(String path) {
final File file = File(path);
if (!file.existsSync()) return t.common.file_not_found;
final String ext = path.split('.').last.toLowerCase();
if (!_kAllowedExtensions.contains(ext)) {
return t.staff_profile_attire.upload_file_types_banner;
}
final int size = file.lengthSync();
if (size > _kMaxFileSizeBytes) {
return t.staff_profile_attire.capture.file_size_exceeds;
}
return null;
}
void _showError(BuildContext context, String message) {
debugPrint(message);
UiSnackbar.show(
context,
message: 'Could not access camera or gallery. Please try again.',
message: t.staff_profile_attire.capture.could_not_access_media,
type: UiSnackbarType.error,
margin: const EdgeInsets.all(UiConstants.space4),
);
@@ -150,6 +194,17 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
);
if (_selectedLocalPath == null) return;
final String? error = _validateFile(_selectedLocalPath!);
if (error != null) {
UiSnackbar.show(
context,
message: error,
type: UiSnackbarType.error,
margin: const EdgeInsets.all(UiConstants.space4),
);
return;
}
await cubit.uploadPhoto(widget.item.id, _selectedLocalPath!);
if (context.mounted && cubit.state.status == AttireCaptureStatus.success) {
setState(() {
@@ -160,10 +215,10 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
String _getStatusText(bool hasUploadedPhoto) {
return switch (widget.item.verificationStatus) {
AttireVerificationStatus.approved => 'Approved',
AttireVerificationStatus.rejected => 'Rejected',
AttireVerificationStatus.pending => 'Pending Verification',
_ => hasUploadedPhoto ? 'Pending Verification' : 'Not Uploaded',
AttireVerificationStatus.approved => t.staff_profile_attire.capture.approved,
AttireVerificationStatus.rejected => t.staff_profile_attire.capture.rejected,
AttireVerificationStatus.pending => t.staff_profile_attire.capture.pending_verification,
_ => hasUploadedPhoto ? t.staff_profile_attire.capture.pending_verification : t.staff_profile_attire.capture.not_uploaded,
};
}
@@ -207,7 +262,7 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
if (state.status == AttireCaptureStatus.success) {
UiSnackbar.show(
context,
message: 'Attire image submitted for verification',
message: t.staff_profile_attire.capture.attire_submitted,
type: UiSnackbarType.success,
);
Modular.to.toAttire();
@@ -225,6 +280,10 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
padding: const EdgeInsets.all(UiConstants.space5),
child: Column(
children: <Widget>[
_FileTypesBanner(
message: t.staff_profile_attire.upload_file_types_banner,
),
const SizedBox(height: UiConstants.space4),
ImagePreviewSection(
selectedLocalPath: _selectedLocalPath,
currentPhotoUrl: currentPhotoUrl,
@@ -268,3 +327,43 @@ class _AttireCapturePageState extends State<AttireCapturePage> {
);
}
}
/// Banner displaying accepted file types and size limit for attire upload.
class _FileTypesBanner extends StatelessWidget {
const _FileTypesBanner({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: UiConstants.space4,
vertical: UiConstants.space3,
),
decoration: BoxDecoration(
color: UiColors.tagActive,
borderRadius: BorderRadius.circular(UiConstants.radiusBase),
border: Border.all(color: UiColors.primary.withValues(alpha: 0.3)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
Icon(
UiIcons.info,
size: 20,
color: UiColors.primary,
),
const SizedBox(width: UiConstants.space3),
Expanded(
child: Text(
message,
style: UiTypography.body2r.textSecondary,
),
),
],
),
);
}
}

View File

@@ -78,7 +78,7 @@ class AttirePage extends StatelessWidget {
),
const SizedBox(height: UiConstants.space4),
Text(
'No items found for this filter.',
context.t.staff_profile_attire.capture.no_items_filter,
style: UiTypography.body1m.textSecondary,
),
],

View File

@@ -1,3 +1,4 @@
import 'package:core_localization/core_localization.dart';
import 'package:design_system/design_system.dart';
import 'package:flutter/material.dart';
@@ -28,7 +29,7 @@ class ImagePreviewSection extends StatelessWidget {
return Column(
children: <Widget>[
Text(
'Review the attire item',
context.t.staff_profile_attire.capture.review_attire_item,
style: UiTypography.body1b.textPrimary,
),
const SizedBox(height: UiConstants.space2),
@@ -42,7 +43,7 @@ class ImagePreviewSection extends StatelessWidget {
if (currentPhotoUrl != null) {
return Column(
children: <Widget>[
Text('Your Uploaded Photo', style: UiTypography.body1b.textPrimary),
Text(context.t.staff_profile_attire.capture.your_uploaded_photo, style: UiTypography.body1b.textPrimary),
const SizedBox(height: UiConstants.space2),
AttireImagePreview(imageUrl: currentPhotoUrl),
const SizedBox(height: UiConstants.space4),
@@ -56,7 +57,7 @@ class ImagePreviewSection extends StatelessWidget {
AttireImagePreview(imageUrl: referenceImageUrl),
const SizedBox(height: UiConstants.space4),
Text(
'Example of the item that you need to upload.',
context.t.staff_profile_attire.capture.example_upload_hint,
style: UiTypography.body1b.textSecondary,
textAlign: TextAlign.center,
),
@@ -77,7 +78,7 @@ class ReferenceExample extends StatelessWidget {
Widget build(BuildContext context) {
return Column(
children: <Widget>[
Text('Reference Example', style: UiTypography.body2b.textSecondary),
Text(context.t.staff_profile_attire.capture.reference_example, style: UiTypography.body2b.textSecondary),
const SizedBox(height: UiConstants.space1),
Center(
child: ClipRRect(