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"
|
"other": "$count workers are running late"
|
||||||
},
|
},
|
||||||
"auto_backup_searching": "Auto-backup system is searching for replacements."
|
"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": {
|
"client_reports_common": {
|
||||||
|
|||||||
@@ -1810,6 +1810,45 @@
|
|||||||
"other": "$count trabajadores est\u00e1n llegando tarde"
|
"other": "$count trabajadores est\u00e1n llegando tarde"
|
||||||
},
|
},
|
||||||
"auto_backup_searching": "El sistema de respaldo autom\u00e1tico est\u00e1 buscando reemplazos."
|
"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": {
|
"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/onboarding_status.dart';
|
||||||
export 'src/entities/enums/order_type.dart';
|
export 'src/entities/enums/order_type.dart';
|
||||||
export 'src/entities/enums/payment_status.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/shift_status.dart';
|
||||||
export 'src/entities/enums/staff_industry.dart';
|
export 'src/entities/enums/staff_industry.dart';
|
||||||
export 'src/entities/enums/staff_skill.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,
|
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) {
|
builder: (BuildContext context, CoverageState state) {
|
||||||
final DateTime selectedDate = state.selectedDate ?? DateTime.now();
|
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:intl/intl.dart';
|
||||||
import 'package:krow_domain/krow_domain.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/shift_header.dart';
|
||||||
import 'package:client_coverage/src/presentation/widgets/worker_row.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.
|
/// List of shifts with their workers.
|
||||||
///
|
///
|
||||||
@@ -98,6 +100,26 @@ class CoverageShiftList extends StatelessWidget {
|
|||||||
worker: worker,
|
worker: worker,
|
||||||
shiftStartTime:
|
shiftStartTime:
|
||||||
_formatTime(shift.timeRange.startsAt),
|
_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(),
|
}).toList(),
|
||||||
|
|||||||
@@ -9,15 +9,21 @@ class LateWorkersAlert extends StatelessWidget {
|
|||||||
/// Creates a [LateWorkersAlert].
|
/// Creates a [LateWorkersAlert].
|
||||||
const LateWorkersAlert({
|
const LateWorkersAlert({
|
||||||
required this.lateCount,
|
required this.lateCount,
|
||||||
|
this.onTap,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
/// The number of late workers.
|
/// The number of late workers.
|
||||||
final int lateCount;
|
final int lateCount;
|
||||||
|
|
||||||
|
/// Optional callback invoked when the alert is tapped.
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
padding: const EdgeInsets.all(UiConstants.space3),
|
padding: const EdgeInsets.all(UiConstants.space3),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: UiColors.destructive.withValues(alpha: 0.1),
|
color: UiColors.destructive.withValues(alpha: 0.1),
|
||||||
@@ -52,8 +58,15 @@ class LateWorkersAlert extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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({
|
const WorkerRow({
|
||||||
required this.worker,
|
required this.worker,
|
||||||
required this.shiftStartTime,
|
required this.shiftStartTime,
|
||||||
|
this.showRateButton = false,
|
||||||
|
this.showCancelButton = false,
|
||||||
|
this.onRate,
|
||||||
|
this.onCancel,
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -19,6 +23,18 @@ class WorkerRow extends StatelessWidget {
|
|||||||
/// The formatted shift start time.
|
/// The formatted shift start time.
|
||||||
final String shiftStartTime;
|
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).
|
/// Formats a [DateTime] to a readable time string (h:mm a).
|
||||||
String _formatCheckInTime(DateTime? time) {
|
String _formatCheckInTime(DateTime? time) {
|
||||||
if (time == null) return '';
|
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