feat(review): implement worker review functionality with rating, feedback, and issue flags

This commit is contained in:
Achintha Isuru
2026-03-18 19:01:51 -04:00
parent 2d452f65e6
commit b317c53e7e
10 changed files with 808 additions and 33 deletions

View File

@@ -69,6 +69,21 @@ class _CoveragePageState extends State<CoveragePage> {
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();

View File

@@ -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<CoverageBloc>();
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(UiConstants.space4),
),
),
builder: (_) => BlocProvider<CoverageBloc>.value(
value: bloc,
child: CancelLateWorkerSheet(worker: worker),
),
);
}
@override
State<CancelLateWorkerSheet> createState() => _CancelLateWorkerSheetState();
}
class _CancelLateWorkerSheetState extends State<CancelLateWorkerSheet> {
/// 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: <Widget>[
// 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: <Widget>[
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: <Widget>[
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<CoverageBloc>().add(
CoverageCancelLateWorkerRequested(
assignmentId: widget.worker.assignmentId,
reason: reason.isNotEmpty ? reason : null,
),
);
Navigator.of(context).pop();
}
}

View File

@@ -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(),

View File

@@ -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: <Widget>[
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: <Widget>[
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: <Widget>[
const Icon(
UiIcons.warning,
color: UiColors.destructive,
),
),
],
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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,
),
],
),
),
);
}

View File

@@ -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<CoverageBloc>();
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(UiConstants.space4),
),
),
builder: (_) => BlocProvider<CoverageBloc>.value(
value: bloc,
child: WorkerReviewSheet(worker: worker),
),
);
}
@override
State<WorkerReviewSheet> createState() => _WorkerReviewSheetState();
}
class _WorkerReviewSheetState extends State<WorkerReviewSheet> {
int _rating = 0;
bool _isFavorite = false;
bool _isBlocked = false;
final Set<ReviewIssueFlag> _selectedFlags = <ReviewIssueFlag>{};
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<String> ratingLabels = <String>[
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: <Widget>[
_buildDragHandle(),
const SizedBox(height: UiConstants.space4),
_buildHeader(context, l10n),
const SizedBox(height: UiConstants.space5),
Flexible(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
_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<CoverageBloc, CoverageState>(
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: <Widget>[
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
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<String> ratingLabels) {
return Column(
children: <Widget>[
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List<Widget>.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) ...<Widget>[
const SizedBox(height: UiConstants.space2),
Text(
ratingLabels[_rating - 1],
style: UiTypography.body2r.textSecondary,
textAlign: TextAlign.center,
),
],
],
);
}
Widget _buildToggles(TranslationsClientCoverageReviewEn l10n) {
return Row(
children: <Widget>[
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: <Widget>[
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<ReviewIssueFlag, String> flagLabels =
<ReviewIssueFlag, String>{
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<CoverageBloc>().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();
}
}

View File

@@ -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: <Widget>[
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: <Widget>[
const Icon(
UiIcons.close,
size: 12,
color: UiColors.destructive,
),
Text(
l10n.actions.cancel,
style: UiTypography.footnote2b.copyWith(
color: UiColors.destructive,
),
),
],
),
),
),
],
),
],