Files
votianlt/app/lib/task_view.dart
T
Sven 5e6044e4b1 fix: Stationsabschluss absichern und Kundenformulare anpassen
Sperrt nachträgliche Aufgabenabschlüsse in App und Backend und zeigt erledigte Aufträge trotz offener optionaler Aufgaben grün an. Ergänzt Regressionstests, die Anredeauswahl und optionale Steuerangaben für Kunden. Enthält App-Version 0.9.25, Backend-Version 0.9.26 und das aktualisierte Frontend-Bundle.
2026-10-06 15:09:51 +02:00

1084 lines
35 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'package:votianlt_app/services/developer.dart' as developer;
import 'package:image/image.dart' as img;
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'app_theme.dart';
import 'app_state.dart';
import 'l10n/app_localizations.dart';
import 'l10n/localization_helpers.dart';
import 'models/job.dart';
import 'models/job_progress.dart';
import 'models/task.dart';
import 'models/tasks/confirmation_task.dart';
import 'models/tasks/photo_task.dart';
import 'models/tasks/todolist_task.dart';
import 'models/tasks/signature_task.dart';
import 'models/tasks/barcode_task.dart';
import 'models/tasks/comment_task.dart';
import 'services/database_service.dart';
import 'widgets/offline_banner.dart';
import 'services/websocket_service.dart';
import 'Tasks/photo_capture_screen.dart';
import 'Tasks/barcode_capture_screen.dart';
import 'Tasks/signature_capture_screen.dart';
class TaskView extends StatefulWidget {
final Job job;
final int? stationOrder;
final String? stationTitle;
const TaskView({
super.key,
required this.job,
this.stationOrder,
this.stationTitle,
});
@override
State<TaskView> createState() => _TaskViewState();
}
class _TaskViewState extends State<TaskView> {
final Set<String> _completedTasks = {};
final DatabaseService _databaseService = DatabaseService();
late Job _job;
Set<int> _localCompletedStations = {};
StreamSubscription<void>? _jobsUpdatedSubscription;
bool _statusesLoaded = false;
bool _stationCompleted = false;
bool _isCompletingStation = false;
// Store SVG representations of signatures per task for later use
final Map<String, String> _signatureSvgByTask = {};
@override
void initState() {
super.initState();
_job = widget.job;
_jobsUpdatedSubscription = AppState().jobsUpdated.listen(
(_) => _loadTaskStatuses(),
);
_loadTaskStatuses();
}
@override
void didUpdateWidget(covariant TaskView oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(oldWidget.job, widget.job) ||
oldWidget.stationOrder != widget.stationOrder) {
_job = widget.job;
_statusesLoaded = false;
_loadTaskStatuses();
}
}
@override
void dispose() {
_jobsUpdatedSubscription?.cancel();
super.dispose();
}
JobProgress get _progress => JobProgress(
_job,
taskStatuses: {for (final id in _completedTasks) id: true},
localCompletedStations: _localCompletedStations,
);
bool _canCompleteTask(Task task) =>
mounted &&
_statusesLoaded &&
!_isCompletingStation &&
!_stationCompleted &&
_progress.canCompleteTask(task);
List<Task> get _visibleTasks {
final stationOrder = widget.stationOrder;
if (stationOrder == null) {
return _job.tasks;
}
return _job.tasks
.where((task) => task.stationOrder == stationOrder)
.toList();
}
String get _stationCompletionKey =>
'station_completed:${_job.id}:${widget.stationOrder ?? 0}';
/// Load task completion and skipped statuses from database and merge with JSON task states
Future<void> _loadTaskStatuses() async {
var latestJob = _job;
for (final savedJob in await _databaseService.loadJobs()) {
if (savedJob.id == _job.id &&
!savedJob.updatedAt.isBefore(latestJob.updatedAt)) {
latestJob = savedJob;
}
}
final statuses = await _databaseService.loadAllTaskStatuses();
final completedStations = await _databaseService.loadCompletedStationOrders(
latestJob,
);
if (!mounted) return;
setState(() {
_job = latestJob;
_localCompletedStations = completedStations;
_statusesLoaded = true;
_completedTasks.clear();
// 1) Add all completed from DB
for (final entry in statuses.entries) {
if (entry.value) {
_completedTasks.add(entry.key);
}
}
// 2) Merge: also mark tasks completed if the job JSON already had them completed
for (final t in _job.tasks) {
if (t.completed) {
_completedTasks.add(t.id);
}
}
_stationCompleted = _progress.isStationClosed(widget.stationOrder ?? 0);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(
widget.stationTitle?.isNotEmpty == true
? '${AppLocalizations.of(context).tasks} - ${widget.stationTitle}'
: '${AppLocalizations.of(context).tasks} - ${_job.jobNumber}',
),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
Navigator.of(context).pop();
},
),
actions: [
IconButton(
icon: const Icon(Icons.chat),
onPressed: () {
Navigator.of(context).pushNamed('/chats');
},
tooltip: AppLocalizations.of(context).openChat,
),
],
),
body: Column(
children: [
OfflineBanner(),
if (_getRemark().isNotEmpty)
Container(
width: double.infinity,
margin: const EdgeInsets.all(5),
constraints: const BoxConstraints(maxHeight: 150),
padding: const EdgeInsets.all(12.0),
decoration: BoxDecoration(
color: AppColors.surfaceMuted,
border: Border.all(color: AppColors.border, width: 1),
borderRadius: BorderRadius.circular(8),
),
child: SingleChildScrollView(
child: Text(
_getRemark(),
style: const TextStyle(fontSize: 14, color: AppColors.text),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: _buildTasksStepper()),
if (_visibleTasks.isNotEmpty) ...[
const SizedBox(height: 12),
_buildCompleteStationButton(),
],
],
),
),
),
],
),
);
}
bool get _canCompleteStation {
if (!_statusesLoaded || _stationCompleted || _isCompletingStation) {
return false;
}
// Station kann abgeschlossen werden, wenn alle nicht-optionalen Aufgaben
// erledigt sind und der Stationsabschluss noch nicht gesendet wurde.
for (final t in _visibleTasks) {
if (!t.optional && !_completedTasks.contains(t.id)) {
return false;
}
}
return true;
}
bool get _hasIncompleteOptionalTasks {
for (final t in _visibleTasks) {
if (t.optional && !_completedTasks.contains(t.id)) {
return true;
}
}
return false;
}
Widget _buildCompleteStationButton() {
final bool enabled = _canCompleteStation;
return SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: enabled ? _onCompleteStationPressed : null,
icon: const Icon(Icons.flag),
label: Text(
_stationCompleted ? 'Station abgeschlossen' : 'Station abschließen',
),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
);
}
void _onCompleteStationPressed() {
if (!_canCompleteStation) return;
if (_hasIncompleteOptionalTasks) {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: const Text('Offene optionale Aufgaben'),
content: const Text(
'Es gibt nicht erledigte optionale Aufgaben. Möchten Sie die Station trotzdem abschließen?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(AppLocalizations.of(context).cancel),
),
ElevatedButton(
onPressed: () {
Navigator.of(ctx).pop();
_sendStationCompleted();
},
child: const Text('Trotzdem abschließen'),
),
],
);
},
);
} else {
_sendStationCompleted();
}
}
Future<void> _sendStationCompleted() async {
if (!_canCompleteStation) return;
setState(() => _isCompletingStation = true);
final stationOrder = widget.stationOrder ?? 0;
try {
await StompService().sendStationCompleted(
jobId: _job.id,
jobNumber: _job.jobNumber,
stationOrder: stationOrder,
hasIncompleteOptionalTasks: _hasIncompleteOptionalTasks,
);
_stationCompleted = true;
_localCompletedStations.add(stationOrder);
await _databaseService.saveKeyValue(_stationCompletionKey, '1');
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('Station abgeschlossen')));
Navigator.of(context).pop();
} catch (e) {
developer.log('Error sending station completion: $e', name: 'TaskView');
} finally {
if (mounted) {
setState(() => _isCompletingStation = false);
}
}
}
Widget _buildTasksStepper() {
if (_visibleTasks.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.task_outlined,
size: 64,
color: AppColors.textMuted,
),
const SizedBox(height: 16),
Text(
AppLocalizations.of(context).noTasks,
style: const TextStyle(fontSize: 16, color: AppColors.textMuted),
),
const SizedBox(height: 8),
Text(
AppLocalizations.of(context).noTasksMessage,
style: const TextStyle(fontSize: 14, color: AppColors.textMuted),
textAlign: TextAlign.center,
),
],
),
);
}
return ListView.builder(
itemCount: _visibleTasks.length,
itemBuilder: (context, index) {
final task = _visibleTasks[index];
final isCompleted = _completedTasks.contains(task.id);
final canBeCompletedNow =
_canCompleteTask(task) && _arePreviousTasksCompleted(index);
// Hintergrundfarbe je nach Status:
// abgeschlossen → hellgrün, bearbeitbar → weiß, gesperrt → hellgrau
// (Optionale Aufgaben werden durch einen Chip markiert, nicht per Farbe.)
final Color cardColor =
isCompleted
? AppColors.successSoft
: canBeCompletedNow
? AppColors.surface
: AppColors.surfaceMuted;
final Color borderColor =
isCompleted
? AppColors.success.withValues(alpha: 0.35)
: canBeCompletedNow
? AppColors.border
: AppColors.border.withValues(alpha: 0.7);
final Color circleColor =
isCompleted
? AppColors.success
: canBeCompletedNow
? AppColors.primary
: AppColors.textMuted;
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: isCompleted || canBeCompletedNow ? 2 : 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: borderColor, width: 1),
),
child: InkWell(
onTap:
canBeCompletedNow
? () => _showTaskCompletionDialog(task, index)
: null,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
color: cardColor,
),
child: Stack(
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Task number circle
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: circleColor,
),
child: Center(
child: Text(
'${index + 1}',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
),
),
const SizedBox(width: 16),
// Task content
Expanded(
child: Padding(
padding: EdgeInsets.only(
right: task.optional ? 72 : 0,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTaskDisplayText(task, isCompleted, index),
if (_getTaskStationLabel(task) != null) ...[
const SizedBox(height: 4),
Text(
_getTaskStationLabel(task)!,
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
fontWeight: FontWeight.w500,
),
),
],
],
),
),
),
if (isCompleted) ...[
const SizedBox(width: 8),
const Icon(
Icons.check_circle,
color: AppColors.success,
),
],
],
),
if (task.optional)
Positioned.fill(
child: Align(
alignment: Alignment.centerRight,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 4,
),
decoration: BoxDecoration(
color: AppColors.warningSoft,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.warning.withValues(alpha: 0.5),
width: 1,
),
),
child: const Text(
'Optional',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: AppColors.warning,
),
),
),
),
),
],
),
),
),
);
},
);
}
void _showTaskCompletionDialog(Task task, int taskIndex) {
if (!_canCompleteTask(task)) return;
switch (task) {
case ConfirmationTask():
_showConfirmationDialog(task, taskIndex);
break;
case PhotoTask():
_showPhotoDialog(task);
break;
case TodoListTask():
_showTodoListDialog(task);
break;
case SignatureTask():
_showSignatureDialog(task);
break;
case BarcodeTask():
_showBarcodeDialog(task);
break;
case CommentTask():
_showCommentDialog(task);
break;
default:
_showGenericDialog(task);
break;
}
}
void _showConfirmationDialog(ConfirmationTask task, int taskIndex) {
final description =
task.description?.isNotEmpty == true
? task.description!
: AppLocalizations.of(context).confirmationDescription;
final buttonText =
task.buttonText.isNotEmpty
? task.buttonText
: AppLocalizations.of(context).confirm;
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(AppLocalizations.of(context).confirmationRequired),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [Text(description)],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(AppLocalizations.of(context).cancel),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
_completeTask(task.id, taskType: 'CONFIRMATION');
},
child: Text(buttonText),
),
],
);
},
);
}
// Compress photos and Base64-encode while keeping total payload under a cap
Future<List<String>> _compressAndEncodePhotos(
List<Uint8List> photos, {
int maxDim = 1280,
int jpegQuality = 70,
int maxTotalBase64Bytes = 450 * 1024,
}) async {
final List<String> encoded = [];
int total = 0;
for (final bytes in photos) {
try {
final img.Image? decoded = img.decodeImage(bytes);
if (decoded == null) {
continue;
}
// Resize if needed keeping aspect ratio
final int w = decoded.width;
final int h = decoded.height;
img.Image resized = decoded;
final int longest = w > h ? w : h;
if (longest > maxDim) {
if (w >= h) {
resized = img.copyResize(decoded, width: maxDim);
} else {
resized = img.copyResize(decoded, height: maxDim);
}
}
final List<int> jpg = img.encodeJpg(resized, quality: jpegQuality);
final String b64 = base64Encode(jpg);
// Respect total payload cap
if (total + b64.length > maxTotalBase64Bytes) {
break;
}
encoded.add(b64);
total += b64.length;
} catch (e, st) {
developer.log('Photo compress/encode error: $e', name: 'TaskView');
developer.log('Stack: $st', name: 'TaskView');
}
}
return encoded;
}
void _showPhotoDialog(PhotoTask task) {
Navigator.of(context).push(
MaterialPageRoute(
builder:
(context) => PhotoCaptureScreen(
task: task,
onPhotosCompleted: (List<Uint8List> photoData) async {
if (!_canCompleteTask(task)) return;
// Compress + encode photos for network send (limit payload)
final List<String> base64List = await _compressAndEncodePhotos(
photoData,
);
if (!_canCompleteTask(task)) return;
final bool truncated = base64List.length < photoData.length;
// Try to persist full-quality (encoded) photos to DB for offline/backup
try {
// Persist the compressed versions to keep DB size reasonable as well
await _databaseService.saveTaskPhotos(task.id, base64List);
} catch (e, stackTrace) {
developer.log(
'Error saving task photos: $e',
name: 'TaskView',
);
developer.log('Stack trace: $stackTrace', name: 'TaskView');
}
// Always complete the task regardless of persistence success/failure
_completeTask(
task.id,
taskType: 'PHOTO',
extraData: {
'photos': base64List,
'count': photoData.length,
if (truncated) 'truncated': true,
},
);
},
),
),
);
}
void _showTodoListDialog(TodoListTask task) {
final items = task.todoItems;
final List<bool> checkedItems = List.filled(items.length, false);
showDialog(
context: context,
builder: (BuildContext context) {
return StatefulBuilder(
builder: (context, setState) {
return AlertDialog(
title: Text(AppLocalizations.of(context).checklist),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(AppLocalizations.of(context).checklistDescription),
const SizedBox(height: 16),
...items.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
return CheckboxListTile(
title: Text(item),
value: checkedItems[index],
onChanged: (bool? value) {
setState(() {
checkedItems[index] = value ?? false;
});
},
dense: true,
contentPadding: EdgeInsets.zero,
);
}),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(AppLocalizations.of(context).abort),
),
ElevatedButton(
onPressed:
checkedItems.every((checked) => checked)
? () {
Navigator.of(context).pop();
_completeTask(
task.id,
taskType: 'TODOLIST',
extraData: {
'items': task.todoItems,
'checkedStates': checkedItems,
},
);
}
: null,
child: Text(AppLocalizations.of(context).finish),
),
],
);
},
);
},
);
}
void _showSignatureDialog(SignatureTask task) {
Navigator.of(context).push(
MaterialPageRoute(
builder:
(context) => SignatureCaptureScreen(
task: task,
onSignatureCompleted: (String svg, String note) async {
if (!_canCompleteTask(task)) return;
try {
// Persist SVG only (no PNG)
await _databaseService.saveTaskSignature(task.id, svg);
await _databaseService.saveTaskSignatureNote(task.id, note);
} catch (e, stackTrace) {
developer.log(
'Error saving task signature: $e',
name: 'TaskView',
);
developer.log('Stack trace: $stackTrace', name: 'TaskView');
}
if (!_canCompleteTask(task)) return;
// Store SVG for later use in this TaskView session
setState(() {
_signatureSvgByTask[task.id] = svg;
});
// Read back once (for analyzer to see it used) and optional debug
debugPrint(
'Signature SVG stored for task ${task.id}: length=${_signatureSvgByTask[task.id]?.length ?? 0}',
);
_completeTask(
task.id,
taskType: 'SIGNATURE',
extraData: {
'signatureSvg': svg,
'svgLength': svg.length,
'hasSignature': true,
'signatureNote': note,
},
);
},
),
),
);
}
void _showBarcodeDialog(BarcodeTask task) {
Navigator.of(context).push(
MaterialPageRoute(
builder:
(context) => BarcodeCaptureScreen(
task: task,
onBarcodesCompleted: (List<String> barcodes) async {
if (!_canCompleteTask(task)) return;
try {
// Save barcodes to database for later use
await _databaseService.saveTaskBarcodes(task.id, barcodes);
} catch (e, stackTrace) {
developer.log(
'Error saving task barcodes: $e',
name: 'TaskView',
);
developer.log('Stack trace: $stackTrace', name: 'TaskView');
}
_completeTask(
task.id,
taskType: 'BARCODE',
extraData: {'barcodes': barcodes, 'count': barcodes.length},
);
},
),
),
);
}
void _showGenericDialog(Task task) {
final TextEditingController noteController = TextEditingController();
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(AppLocalizations.of(context).completeTask),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(AppLocalizations.of(context).completeTaskConfirm),
const SizedBox(height: 16),
TextField(
controller: noteController,
decoration: InputDecoration(
labelText: AppLocalizations.of(context).completeTaskNote,
border: const OutlineInputBorder(),
),
maxLines: 3,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(AppLocalizations.of(context).abort),
),
ElevatedButton(
onPressed: () {
Navigator.of(context).pop();
_completeTask(task.id, taskType: 'GENERIC');
},
child: Text(AppLocalizations.of(context).complete),
),
],
);
},
);
}
void _completeTask(
String taskId, {
String? taskType,
Map<String, dynamic>? extraData,
}) {
final tasks = _visibleTasks.where((task) => task.id == taskId);
if (tasks.isEmpty || !_canCompleteTask(tasks.first)) return;
final bool hadOpenMandatoryBefore = _hasOpenMandatoryTasks;
setState(() {
_completedTasks.add(taskId);
});
// Save to database
_databaseService.saveTaskStatus(taskId, true);
// Notify server via STOMP about task completion (best-effort)
try {
StompService().sendTaskCompleted(
taskId: taskId,
taskType: taskType,
extraData: extraData,
);
} catch (e) {
developer.log('Error sending task completion: $e', name: 'TaskView');
}
// Wenn die letzte nicht-optionale Aufgabe gerade erledigt wurde,
// den Benutzer fragen, ob er die Station jetzt abschließen möchte.
if (hadOpenMandatoryBefore && !_hasOpenMandatoryTasks) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _canCompleteStation) {
_showLastMandatoryCompletedDialog();
}
});
}
}
bool get _hasOpenMandatoryTasks {
for (final t in _visibleTasks) {
if (!t.optional && !_completedTasks.contains(t.id)) {
return true;
}
}
return false;
}
void _showLastMandatoryCompletedDialog() {
showDialog(
context: context,
builder: (BuildContext ctx) {
return AlertDialog(
title: const Text('Alle Pflichtaufgaben erledigt'),
content: const Text(
'Alle nicht optionalen Aufgaben dieser Station sind erledigt. '
'Möchten Sie die Station jetzt abschließen?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Später'),
),
ElevatedButton(
onPressed: () {
Navigator.of(ctx).pop();
_onCompleteStationPressed();
},
child: const Text('Station abschließen'),
),
],
);
},
);
}
bool _arePreviousTasksCompleted(int index) {
if (index <= 0) return true;
for (int i = 0; i < index; i++) {
final t = _visibleTasks[i];
if (!t.optional && !_completedTasks.contains(t.id)) {
return false;
}
}
return true;
}
void _showCommentDialog(CommentTask task) {
final TextEditingController commentController = TextEditingController();
commentController.text = task.commentText;
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text(AppLocalizations.of(context).enterComment),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(AppLocalizations.of(context).commentDescription),
const SizedBox(height: 16),
TextField(
controller: commentController,
decoration: InputDecoration(
labelText:
task.required
? AppLocalizations.of(context).commentRequired
: AppLocalizations.of(context).comment,
border: const OutlineInputBorder(),
hintText: '...',
),
maxLines: 4,
minLines: 2,
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(AppLocalizations.of(context).abort),
),
ElevatedButton(
onPressed: () {
final comment = commentController.text.trim();
if (task.required && comment.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context).commentRequired,
),
),
);
return;
}
Navigator.of(context).pop();
_completeTask(
task.id,
taskType: 'COMMENT',
extraData: {
'commentText': comment,
'required': task.required,
},
);
},
child: Text(AppLocalizations.of(context).save),
),
],
);
},
);
}
Widget _buildTaskDisplayText(Task task, bool isCompleted, int taskIndex) {
final titleStyle = TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: Colors.grey[800],
decoration: isCompleted ? TextDecoration.lineThrough : null,
);
final subtitleStyle = TextStyle(
fontSize: 13,
color: Colors.grey[600],
decoration: isCompleted ? TextDecoration.lineThrough : null,
);
final displayName =
task.displayName != null
? localizeKnownText(context, task.displayName!)
: null;
final description =
task.description != null
? localizeKnownText(context, task.description!)
: null;
final String? signatureNote =
(task is SignatureTask &&
task.note != null &&
task.note!.trim().isNotEmpty)
? task.note!.trim()
: null;
if (displayName?.isNotEmpty == true) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(displayName!, style: titleStyle),
if (description?.isNotEmpty == true) ...[
const SizedBox(height: 2),
Text(description!, style: subtitleStyle),
],
if (signatureNote != null) ...[
const SizedBox(height: 2),
Text(signatureNote, style: subtitleStyle),
],
],
);
}
if (description?.isNotEmpty == true) {
if (signatureNote != null) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(description!, style: titleStyle),
const SizedBox(height: 2),
Text(signatureNote, style: subtitleStyle),
],
);
}
return Text(description!, style: titleStyle);
}
if (signatureNote != null) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_getStandardTaskDisplayText(task), style: titleStyle),
const SizedBox(height: 2),
Text(signatureNote, style: subtitleStyle),
],
);
}
// Fall back to standard text based on task type
return Text(_getStandardTaskDisplayText(task), style: titleStyle);
}
String _getStandardTaskDisplayText(Task task) {
// Generate display text based on task type
switch (task) {
case PhotoTask():
return '${AppLocalizations.of(context).takePhotos} (${task.minPhotoCount}-${task.maxPhotoCount} ${AppLocalizations.of(context).photosCount})';
case TodoListTask():
return '${AppLocalizations.of(context).checklist} (${task.todoItems.length} ${AppLocalizations.of(context).checklistPoints})';
case SignatureTask():
return AppLocalizations.of(context).signatureRequiredText;
case BarcodeTask():
return '${AppLocalizations.of(context).scanBarcodes} (${task.minBarcodeCount}-${task.maxBarcodeCount} ${AppLocalizations.of(context).barcodeCount})';
case CommentTask():
return task.required
? AppLocalizations.of(context).commentRequired
: AppLocalizations.of(context).commentOptional;
default:
return AppLocalizations.of(context).genericTask;
}
}
String _getRemark() => _job.remark;
String? _getTaskStationLabel(Task task) {
if (widget.stationOrder != null) {
return null;
}
final stationOrder = task.stationOrder;
if (stationOrder == null) {
return null;
}
for (final station in _job.deliveryStations) {
if (station.stationOrder == stationOrder) {
final suffix =
station.displayName.isNotEmpty ? station.displayName : station.city;
return localizedStationLabel(context, stationOrder + 1, suffix: suffix);
}
}
return AppLocalizations.of(context).stationNumber(stationOrder + 1);
}
}