feat(review): implement worker review functionality with rating, feedback, and issue flags
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user