From b317c53e7e97fbddd976c15a750864a7badfbac7 Mon Sep 17 00:00:00 2001 From: Achintha Isuru Date: Wed, 18 Mar 2026 19:01:51 -0400 Subject: [PATCH] feat(review): implement worker review functionality with rating, feedback, and issue flags --- .../lib/src/l10n/en.i18n.json | 39 +++ .../lib/src/l10n/es.i18n.json | 39 +++ .../packages/domain/lib/krow_domain.dart | 1 + .../src/entities/enums/review_issue_flag.dart | 46 +++ .../src/presentation/pages/coverage_page.dart | 15 + .../widgets/cancel_late_worker_sheet.dart | 189 ++++++++++ .../widgets/coverage_shift_list.dart | 22 ++ .../widgets/late_workers_alert.dart | 79 +++-- .../widgets/worker_review_sheet.dart | 328 ++++++++++++++++++ .../src/presentation/widgets/worker_row.dart | 83 +++++ 10 files changed, 808 insertions(+), 33 deletions(-) create mode 100644 apps/mobile/packages/domain/lib/src/entities/enums/review_issue_flag.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/cancel_late_worker_sheet.dart create mode 100644 apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_review_sheet.dart diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json index b47259ac..b0eb7570 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/en.i18n.json @@ -1810,6 +1810,45 @@ "other": "$count workers are running late" }, "auto_backup_searching": "Auto-backup system is searching for replacements." + }, + "review": { + "title": "Rate this worker", + "subtitle": "Share your feedback", + "rating_labels": { + "poor": "Poor", + "fair": "Fair", + "good": "Good", + "great": "Great", + "excellent": "Excellent" + }, + "favorite_label": "Favorite", + "block_label": "Block", + "feedback_placeholder": "Share details about this worker's performance...", + "submit": "Submit Review", + "success": "Review submitted successfully", + "issue_flags": { + "late": "Late", + "uniform": "Uniform", + "misconduct": "Misconduct", + "no_show": "No Show", + "attitude": "Attitude", + "performance": "Performance", + "left_early": "Left Early" + } + }, + "cancel": { + "title": "Cancel Worker?", + "subtitle": "This cannot be undone", + "confirm_message": "Are you sure you want to cancel $name?", + "helper_text": "They will receive a cancellation notification. A replacement will be automatically requested.", + "reason_placeholder": "Reason for cancellation (optional)", + "keep_worker": "Keep Worker", + "confirm": "Yes, Cancel", + "success": "Worker cancelled. Searching for replacement." + }, + "actions": { + "rate": "Rate", + "cancel": "Cancel" } }, "client_reports_common": { diff --git a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json index 79fea836..f61a721c 100644 --- a/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json +++ b/apps/mobile/packages/core_localization/lib/src/l10n/es.i18n.json @@ -1810,6 +1810,45 @@ "other": "$count trabajadores est\u00e1n llegando tarde" }, "auto_backup_searching": "El sistema de respaldo autom\u00e1tico est\u00e1 buscando reemplazos." + }, + "review": { + "title": "Calificar a este trabajador", + "subtitle": "Comparte tu opini\u00f3n", + "rating_labels": { + "poor": "Malo", + "fair": "Regular", + "good": "Bueno", + "great": "Muy Bueno", + "excellent": "Excelente" + }, + "favorite_label": "Favorito", + "block_label": "Bloquear", + "feedback_placeholder": "Comparte detalles sobre el desempe\u00f1o de este trabajador...", + "submit": "Enviar Rese\u00f1a", + "success": "Rese\u00f1a enviada exitosamente", + "issue_flags": { + "late": "Tarde", + "uniform": "Uniforme", + "misconduct": "Mala Conducta", + "no_show": "No Se Present\u00f3", + "attitude": "Actitud", + "performance": "Rendimiento", + "left_early": "Sali\u00f3 Temprano" + } + }, + "cancel": { + "title": "\u00bfCancelar Trabajador?", + "subtitle": "Esta acci\u00f3n no se puede deshacer", + "confirm_message": "\u00bfEst\u00e1s seguro de que deseas cancelar a $name?", + "helper_text": "Recibir\u00e1n una notificaci\u00f3n de cancelaci\u00f3n. Se solicitar\u00e1 un reemplazo autom\u00e1ticamente.", + "reason_placeholder": "Raz\u00f3n de la cancelaci\u00f3n (opcional)", + "keep_worker": "Mantener Trabajador", + "confirm": "S\u00ed, Cancelar", + "success": "Trabajador cancelado. Buscando reemplazo." + }, + "actions": { + "rate": "Calificar", + "cancel": "Cancelar" } }, "client_reports_common": { diff --git a/apps/mobile/packages/domain/lib/krow_domain.dart b/apps/mobile/packages/domain/lib/krow_domain.dart index c0122529..62f8dd73 100644 --- a/apps/mobile/packages/domain/lib/krow_domain.dart +++ b/apps/mobile/packages/domain/lib/krow_domain.dart @@ -18,6 +18,7 @@ export 'src/entities/enums/invoice_status.dart'; export 'src/entities/enums/onboarding_status.dart'; export 'src/entities/enums/order_type.dart'; export 'src/entities/enums/payment_status.dart'; +export 'src/entities/enums/review_issue_flag.dart'; export 'src/entities/enums/shift_status.dart'; export 'src/entities/enums/staff_industry.dart'; export 'src/entities/enums/staff_skill.dart'; diff --git a/apps/mobile/packages/domain/lib/src/entities/enums/review_issue_flag.dart b/apps/mobile/packages/domain/lib/src/entities/enums/review_issue_flag.dart new file mode 100644 index 00000000..4604b5d5 --- /dev/null +++ b/apps/mobile/packages/domain/lib/src/entities/enums/review_issue_flag.dart @@ -0,0 +1,46 @@ +/// Issue flags that can be attached to a worker review. +/// +/// Maps to the allowed values for the `issue_flags` field in the +/// V2 coverage reviews endpoint. +enum ReviewIssueFlag { + /// Worker arrived late. + late('LATE'), + + /// Uniform violation. + uniform('UNIFORM'), + + /// Worker misconduct. + misconduct('MISCONDUCT'), + + /// Worker did not show up. + noShow('NO_SHOW'), + + /// Attitude issue. + attitude('ATTITUDE'), + + /// Performance issue. + performance('PERFORMANCE'), + + /// Worker left before shift ended. + leftEarly('LEFT_EARLY'), + + /// Fallback for unrecognised API values. + unknown('UNKNOWN'); + + const ReviewIssueFlag(this.value); + + /// The V2 API string representation. + final String value; + + /// Deserialises from a V2 API string with safe fallback. + static ReviewIssueFlag fromJson(String? value) { + if (value == null) return ReviewIssueFlag.unknown; + for (final ReviewIssueFlag flag in ReviewIssueFlag.values) { + if (flag.value == value) return flag; + } + return ReviewIssueFlag.unknown; + } + + /// Serialises to the V2 API string. + String toJson() => value; +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart index 291234f6..d540735a 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/pages/coverage_page.dart @@ -69,6 +69,21 @@ class _CoveragePageState extends State { type: UiSnackbarType.error, ); } + if (state.writeStatus == CoverageWriteStatus.submitted) { + UiSnackbar.show( + context, + message: context.t.client_coverage.review.success, + type: UiSnackbarType.success, + ); + } + if (state.writeStatus == CoverageWriteStatus.submitFailure && + state.writeErrorMessage != null) { + UiSnackbar.show( + context, + message: translateErrorKey(state.writeErrorMessage!), + type: UiSnackbarType.error, + ); + } }, builder: (BuildContext context, CoverageState state) { final DateTime selectedDate = state.selectedDate ?? DateTime.now(); diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/cancel_late_worker_sheet.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/cancel_late_worker_sheet.dart new file mode 100644 index 00000000..dc4ad5fe --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/cancel_late_worker_sheet.dart @@ -0,0 +1,189 @@ +import 'package:core_localization/core_localization.dart'; +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:client_coverage/src/presentation/blocs/coverage_bloc.dart'; +import 'package:client_coverage/src/presentation/blocs/coverage_event.dart'; + +/// Bottom sheet modal for cancelling a late worker's assignment. +/// +/// Collects an optional cancellation reason and dispatches a +/// [CoverageCancelLateWorkerRequested] event to the [CoverageBloc]. +class CancelLateWorkerSheet extends StatefulWidget { + /// Creates a [CancelLateWorkerSheet]. + const CancelLateWorkerSheet({ + required this.worker, + super.key, + }); + + /// The assigned worker to cancel. + final AssignedWorker worker; + + /// Shows the cancel-late-worker bottom sheet. + /// + /// Captures [CoverageBloc] from [context] before opening so the sheet + /// can dispatch events without relying on an ancestor that may be + /// deactivated. + static void show(BuildContext context, {required AssignedWorker worker}) { + final CoverageBloc bloc = ReadContext(context).read(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(UiConstants.space4), + ), + ), + builder: (_) => BlocProvider.value( + value: bloc, + child: CancelLateWorkerSheet(worker: worker), + ), + ); + } + + @override + State createState() => _CancelLateWorkerSheetState(); +} + +class _CancelLateWorkerSheetState extends State { + /// Controller for the optional cancellation reason text field. + final TextEditingController _reasonController = TextEditingController(); + + @override + void dispose() { + _reasonController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final TranslationsClientCoverageCancelEn l10n = + context.t.client_coverage.cancel; + + return Padding( + padding: EdgeInsets.only( + left: UiConstants.space4, + right: UiConstants.space4, + top: UiConstants.space3, + bottom: MediaQuery.of(context).viewInsets.bottom + UiConstants.space4, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: UiColors.border, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: UiConstants.space4), + + // Header + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Icon( + UiIcons.warning, + color: UiColors.destructive, + size: 28, + ), + GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: Icon( + UiIcons.close, + color: UiColors.textSecondary, + size: 24, + ), + ), + ], + ), + const SizedBox(height: UiConstants.space2), + Text(l10n.title, style: UiTypography.title1b), + const SizedBox(height: UiConstants.space1), + Text( + l10n.subtitle, + style: UiTypography.body2r.copyWith(color: UiColors.destructive), + ), + const SizedBox(height: UiConstants.space4), + + // Body + Text( + l10n.confirm_message(name: widget.worker.fullName), + style: UiTypography.body1m, + ), + const SizedBox(height: UiConstants.space2), + Text( + l10n.helper_text, + style: UiTypography.body2r.textSecondary, + ), + const SizedBox(height: UiConstants.space4), + + // Reason field + UiTextField( + hintText: l10n.reason_placeholder, + maxLines: 2, + controller: _reasonController, + ), + const SizedBox(height: UiConstants.space4), + + // Action buttons + Row( + children: [ + Expanded( + child: UiButton.secondary( + text: l10n.keep_worker, + onPressed: () => Navigator.of(context).pop(), + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: ElevatedButton( + onPressed: () => _onConfirm(context), + style: ElevatedButton.styleFrom( + backgroundColor: UiColors.destructive, + foregroundColor: UiColors.primaryForeground, + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space3, + ), + shape: RoundedRectangleBorder( + borderRadius: UiConstants.radiusMd, + ), + ), + child: Text( + l10n.confirm, + style: UiTypography.body1b.copyWith( + color: UiColors.primaryForeground, + ), + ), + ), + ), + ], + ), + ], + ), + ); + } + + /// Dispatches the cancel event and closes the sheet. + void _onConfirm(BuildContext context) { + final String reason = _reasonController.text.trim(); + + ReadContext(context).read().add( + CoverageCancelLateWorkerRequested( + assignmentId: widget.worker.assignmentId, + reason: reason.isNotEmpty ? reason : null, + ), + ); + + Navigator.of(context).pop(); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart index 10923545..13b6ce9e 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/coverage_shift_list.dart @@ -4,8 +4,10 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:krow_domain/krow_domain.dart'; +import 'package:client_coverage/src/presentation/widgets/cancel_late_worker_sheet.dart'; import 'package:client_coverage/src/presentation/widgets/shift_header.dart'; import 'package:client_coverage/src/presentation/widgets/worker_row.dart'; +import 'package:client_coverage/src/presentation/widgets/worker_review_sheet.dart'; /// List of shifts with their workers. /// @@ -98,6 +100,26 @@ class CoverageShiftList extends StatelessWidget { worker: worker, shiftStartTime: _formatTime(shift.timeRange.startsAt), + showRateButton: + worker.status == AssignmentStatus.checkedIn || + worker.status == + AssignmentStatus.checkedOut || + worker.status == + AssignmentStatus.completed, + showCancelButton: + worker.status == AssignmentStatus.noShow || + worker.status == + AssignmentStatus.assigned || + worker.status == + AssignmentStatus.accepted, + onRate: () => WorkerReviewSheet.show( + context, + worker: worker, + ), + onCancel: () => CancelLateWorkerSheet.show( + context, + worker: worker, + ), ), ); }).toList(), diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/late_workers_alert.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/late_workers_alert.dart index 716512cc..5d1f7bd8 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/late_workers_alert.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/late_workers_alert.dart @@ -9,50 +9,63 @@ class LateWorkersAlert extends StatelessWidget { /// Creates a [LateWorkersAlert]. const LateWorkersAlert({ required this.lateCount, + this.onTap, super.key, }); /// The number of late workers. final int lateCount; + /// Optional callback invoked when the alert is tapped. + final VoidCallback? onTap; + @override Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(UiConstants.space3), - decoration: BoxDecoration( - color: UiColors.destructive.withValues(alpha: 0.1), - borderRadius: UiConstants.radiusLg, - border: Border.all( - color: UiColors.destructive, - width: 0.5, - ), - ), - child: Row( - spacing: UiConstants.space4, - children: [ - const Icon( - UiIcons.warning, + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(UiConstants.space3), + decoration: BoxDecoration( + color: UiColors.destructive.withValues(alpha: 0.1), + borderRadius: UiConstants.radiusLg, + border: Border.all( color: UiColors.destructive, + width: 0.5, ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - context.t.client_coverage.alert - .workers_running_late(n: lateCount, count: lateCount), - style: UiTypography.body1b.textError, - ), - Text( - context.t.client_coverage.alert.auto_backup_searching, - style: UiTypography.body3r.copyWith( - color: UiColors.textError.withValues(alpha: 0.7), - ), - ), - ], + ), + child: Row( + spacing: UiConstants.space4, + children: [ + const Icon( + UiIcons.warning, + color: UiColors.destructive, ), - ), - ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.t.client_coverage.alert + .workers_running_late(n: lateCount, count: lateCount), + style: UiTypography.body1b.textError, + ), + Text( + context.t.client_coverage.alert.auto_backup_searching, + style: UiTypography.body3r.copyWith( + color: UiColors.textError.withValues(alpha: 0.7), + ), + ), + ], + ), + ), + if (onTap != null) + const Icon( + UiIcons.chevronRight, + size: UiConstants.space4, + color: UiColors.destructive, + ), + ], + ), ), ); } diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_review_sheet.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_review_sheet.dart new file mode 100644 index 00000000..8508fdae --- /dev/null +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_review_sheet.dart @@ -0,0 +1,328 @@ +import 'package:core_localization/core_localization.dart'; +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:client_coverage/src/presentation/blocs/coverage_bloc.dart'; +import 'package:client_coverage/src/presentation/blocs/coverage_event.dart'; +import 'package:client_coverage/src/presentation/blocs/coverage_state.dart'; + +class WorkerReviewSheet extends StatefulWidget { + const WorkerReviewSheet({required this.worker, super.key}); + + final AssignedWorker worker; + + static void show(BuildContext context, {required AssignedWorker worker}) { + final CoverageBloc bloc = ReadContext(context).read(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical( + top: Radius.circular(UiConstants.space4), + ), + ), + builder: (_) => BlocProvider.value( + value: bloc, + child: WorkerReviewSheet(worker: worker), + ), + ); + } + + @override + State createState() => _WorkerReviewSheetState(); +} + +class _WorkerReviewSheetState extends State { + int _rating = 0; + bool _isFavorite = false; + bool _isBlocked = false; + final Set _selectedFlags = {}; + final TextEditingController _feedbackController = TextEditingController(); + + @override + void dispose() { + _feedbackController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final TranslationsClientCoverageReviewEn l10n = + context.t.client_coverage.review; + + final List ratingLabels = [ + l10n.rating_labels.poor, + l10n.rating_labels.fair, + l10n.rating_labels.good, + l10n.rating_labels.great, + l10n.rating_labels.excellent, + ]; + + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), + child: Padding( + padding: EdgeInsets.only( + left: UiConstants.space4, + right: UiConstants.space4, + top: UiConstants.space3, + bottom: MediaQuery.of(context).viewInsets.bottom + UiConstants.space4, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildDragHandle(), + const SizedBox(height: UiConstants.space4), + _buildHeader(context, l10n), + const SizedBox(height: UiConstants.space5), + Flexible( + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _buildStarRating(ratingLabels), + const SizedBox(height: UiConstants.space5), + _buildToggles(l10n), + const SizedBox(height: UiConstants.space5), + _buildIssueFlags(l10n), + const SizedBox(height: UiConstants.space4), + UiTextField( + hintText: l10n.feedback_placeholder, + maxLines: 3, + controller: _feedbackController, + ), + ], + ), + ), + ), + const SizedBox(height: UiConstants.space4), + BlocBuilder( + buildWhen: (CoverageState previous, CoverageState current) => + previous.writeStatus != current.writeStatus, + builder: (BuildContext context, CoverageState state) { + return UiButton.primary( + text: l10n.submit, + fullWidth: true, + isLoading: + state.writeStatus == CoverageWriteStatus.submitting, + onPressed: _rating > 0 ? () => _onSubmit(context) : null, + ); + }, + ), + const SizedBox(height: UiConstants.space24), + ], + ), + ), + ); + } + + Widget _buildDragHandle() { + return Center( + child: Container( + width: 40, + height: 4, + decoration: BoxDecoration( + color: UiColors.textDisabled, + borderRadius: BorderRadius.circular(2), + ), + ), + ); + } + + Widget _buildHeader( + BuildContext context, + TranslationsClientCoverageReviewEn l10n, + ) { + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(widget.worker.fullName, style: UiTypography.title1b), + Text(l10n.title, style: UiTypography.body2r.textSecondary), + ], + ), + ), + IconButton( + icon: const Icon(UiIcons.close), + onPressed: () => Navigator.of(context).pop(), + ), + ], + ); + } + + Widget _buildStarRating(List ratingLabels) { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(5, (int index) { + final bool isFilled = index < _rating; + return GestureDetector( + onTap: () => setState(() => _rating = index + 1), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space1, + ), + child: Icon( + UiIcons.star, + size: UiConstants.space8, + color: isFilled + ? UiColors.textWarning + : UiColors.textDisabled, + ), + ), + ); + }), + ), + if (_rating > 0) ...[ + const SizedBox(height: UiConstants.space2), + Text( + ratingLabels[_rating - 1], + style: UiTypography.body2r.textSecondary, + textAlign: TextAlign.center, + ), + ], + ], + ); + } + + Widget _buildToggles(TranslationsClientCoverageReviewEn l10n) { + return Row( + children: [ + Expanded( + child: _buildToggleButton( + icon: Icons.favorite, + label: l10n.favorite_label, + isActive: _isFavorite, + activeColor: const Color(0xFFE91E63), + onTap: () => setState(() => _isFavorite = !_isFavorite), + ), + ), + const SizedBox(width: UiConstants.space3), + Expanded( + child: _buildToggleButton( + icon: UiIcons.ban, + label: l10n.block_label, + isActive: _isBlocked, + activeColor: UiColors.destructive, + onTap: () => setState(() => _isBlocked = !_isBlocked), + ), + ), + ], + ); + } + + Widget _buildToggleButton({ + required IconData icon, + required String label, + required bool isActive, + required Color activeColor, + required VoidCallback onTap, + }) { + final Color bgColor = + isActive ? activeColor.withAlpha(26) : UiColors.muted; + final Color fgColor = + isActive ? activeColor : UiColors.textDisabled; + + return InkWell( + onTap: onTap, + borderRadius: UiConstants.radiusMd, + child: Container( + padding: const EdgeInsets.symmetric( + vertical: UiConstants.space3, + ), + decoration: BoxDecoration( + color: bgColor, + borderRadius: UiConstants.radiusMd, + border: isActive ? Border.all(color: activeColor, width: 0.5) : null, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: UiConstants.space5, color: fgColor), + const SizedBox(width: UiConstants.space2), + Text( + label, + style: UiTypography.body2r.copyWith(color: fgColor), + ), + ], + ), + ), + ); + } + + Widget _buildIssueFlags(TranslationsClientCoverageReviewEn l10n) { + final Map flagLabels = + { + ReviewIssueFlag.late: l10n.issue_flags.late, + ReviewIssueFlag.uniform: l10n.issue_flags.uniform, + ReviewIssueFlag.misconduct: l10n.issue_flags.misconduct, + ReviewIssueFlag.noShow: l10n.issue_flags.no_show, + ReviewIssueFlag.attitude: l10n.issue_flags.attitude, + ReviewIssueFlag.performance: l10n.issue_flags.performance, + ReviewIssueFlag.leftEarly: l10n.issue_flags.left_early, + }; + + return Wrap( + spacing: UiConstants.space2, + runSpacing: UiConstants.space2, + children: ReviewIssueFlag.values.map((ReviewIssueFlag flag) { + final bool isSelected = _selectedFlags.contains(flag); + final String label = flagLabels[flag] ?? flag.value; + + return FilterChip( + label: Text(label), + selected: isSelected, + onSelected: (bool selected) { + setState(() { + if (selected) { + _selectedFlags.add(flag); + } else { + _selectedFlags.remove(flag); + } + }); + }, + selectedColor: UiColors.primary, + labelStyle: isSelected + ? UiTypography.body3r.copyWith(color: UiColors.primaryForeground) + : UiTypography.body3r.textSecondary, + backgroundColor: UiColors.muted, + checkmarkColor: UiColors.primaryForeground, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(UiConstants.space5), + ), + side: isSelected + ? const BorderSide(color: UiColors.primary) + : BorderSide.none, + ); + }).toList(), + ); + } + + void _onSubmit(BuildContext context) { + ReadContext(context).read().add( + CoverageSubmitReviewRequested( + staffId: widget.worker.staffId, + rating: _rating, + assignmentId: widget.worker.assignmentId, + feedback: _feedbackController.text.trim().isNotEmpty + ? _feedbackController.text.trim() + : null, + issueFlags: _selectedFlags.isNotEmpty + ? _selectedFlags + .map((ReviewIssueFlag f) => f.value) + .toList() + : null, + markAsFavorite: _isFavorite ? true : null, + ), + ); + Navigator.of(context).pop(); + } +} diff --git a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_row.dart b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_row.dart index a2018238..1c5aae75 100644 --- a/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_row.dart +++ b/apps/mobile/packages/features/client/client_coverage/lib/src/presentation/widgets/worker_row.dart @@ -10,6 +10,10 @@ class WorkerRow extends StatelessWidget { const WorkerRow({ required this.worker, required this.shiftStartTime, + this.showRateButton = false, + this.showCancelButton = false, + this.onRate, + this.onCancel, super.key, }); @@ -19,6 +23,18 @@ class WorkerRow extends StatelessWidget { /// The formatted shift start time. final String shiftStartTime; + /// Whether to show the rate action button. + final bool showRateButton; + + /// Whether to show the cancel action button. + final bool showCancelButton; + + /// Callback invoked when the rate button is tapped. + final VoidCallback? onRate; + + /// Callback invoked when the cancel button is tapped. + final VoidCallback? onCancel; + /// Formats a [DateTime] to a readable time string (h:mm a). String _formatCheckInTime(DateTime? time) { if (time == null) return ''; @@ -214,6 +230,73 @@ class WorkerRow extends StatelessWidget { ), ), ), + if (showRateButton && onRate != null) + GestureDetector( + onTap: onRate, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: UiConstants.space1 / 2, + ), + decoration: BoxDecoration( + color: UiColors.primary.withAlpha(26), + borderRadius: UiConstants.radiusMd, + border: Border.all(color: UiColors.primary, width: 0.5), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: UiConstants.space1, + children: [ + const Icon( + UiIcons.star, + size: 12, + color: UiColors.primary, + ), + Text( + l10n.actions.rate, + style: UiTypography.footnote2b.copyWith( + color: UiColors.primary, + ), + ), + ], + ), + ), + ), + if (showCancelButton && onCancel != null) + GestureDetector( + onTap: onCancel, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: UiConstants.space2, + vertical: UiConstants.space1 / 2, + ), + decoration: BoxDecoration( + color: UiColors.destructive.withAlpha(26), + borderRadius: UiConstants.radiusMd, + border: Border.all( + color: UiColors.destructive, + width: 0.5, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: UiConstants.space1, + children: [ + const Icon( + UiIcons.close, + size: 12, + color: UiColors.destructive, + ), + Text( + l10n.actions.cancel, + style: UiTypography.footnote2b.copyWith( + color: UiColors.destructive, + ), + ), + ], + ), + ), + ), ], ), ],