Files
votianlt/app/lib/task_view.dart

797 lines
26 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 'l10n/app_localizations.dart';
import 'models/job.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 Set<String> _skippedTasks = {};
final DatabaseService _databaseService = DatabaseService();
// Store SVG representations of signatures per task for later use
final Map<String, String> _signatureSvgByTask = {};
@override
void initState() {
super.initState();
_loadTaskStatuses();
}
List<Task> get _visibleTasks {
final stationOrder = widget.stationOrder;
if (stationOrder == null) {
return widget.job.tasks;
}
return widget.job.tasks
.where((task) => task.stationOrder == stationOrder)
.toList();
}
/// Load task completion statuses from database and merge with JSON task states
Future<void> _loadTaskStatuses() async {
final statuses = await _databaseService.loadAllTaskStatuses();
setState(() {
_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 widget.job.tasks) {
if (t.completed) {
_completedTasks.add(t.id);
}
}
});
}
@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} - ${widget.job.jobNumber}',
),
backgroundColor: Colors.deepPurple[100],
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: const Color(0xFFF8F9FA),
border: Border.all(color: Colors.grey[300]!, width: 1),
borderRadius: BorderRadius.circular(8),
),
child: SingleChildScrollView(
child: Text(
_getRemark(),
style: TextStyle(fontSize: 14, color: Colors.grey[700]),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [Expanded(child: _buildTasksStepper())],
),
),
),
],
),
);
}
Widget _buildTasksStepper() {
if (_visibleTasks.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.task_outlined, size: 64, color: Colors.grey[400]),
const SizedBox(height: 16),
Text(
AppLocalizations.of(context).noTasks,
style: TextStyle(fontSize: 16, color: Colors.grey[600]),
),
const SizedBox(height: 8),
Text(
AppLocalizations.of(context).noTasksMessage,
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
textAlign: TextAlign.center,
),
],
),
);
}
return ListView.builder(
itemCount: _visibleTasks.length,
itemBuilder: (context, index) {
final task = _visibleTasks[index];
final isCompleted = _completedTasks.contains(task.id);
final isSkipped = _skippedTasks.contains(task.id);
final canBeCompletedNow =
!isCompleted && !isSkipped && _arePreviousTasksCompleted(index);
// Hintergrundfarbe je nach Status:
// abgeschlossen → hellgrün, übersprungen → hellgelb, bearbeitbar → weiß, gesperrt → hellgrau
final Color cardColor =
isCompleted
? const Color(0xFFE8F5E9) // hellgrün
: isSkipped
? const Color(0xFFFFF8E1) // hellgelb
: canBeCompletedNow
? Colors.white
: const Color(0xFFF5F5F5); // hellgrau
final Color borderColor =
isCompleted
? Colors.green[300]!
: isSkipped
? Colors.amber[300]!
: canBeCompletedNow
? Colors.grey[300]!
: Colors.grey[200]!;
final Color circleColor =
isCompleted
? Colors.green[600]!
: isSkipped
? Colors.amber[600]!
: canBeCompletedNow
? Colors.deepPurple[400]!
: Colors.grey[400]!;
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: 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: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildTaskDisplayText(
task,
isCompleted || isSkipped,
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),
Icon(Icons.check_circle, color: Colors.green[600]),
],
if (isSkipped) ...[
const SizedBox(width: 8),
Icon(Icons.skip_next, color: Colors.amber[600]),
],
],
),
),
),
);
},
);
}
void _showTaskCompletionDialog(Task task, int taskIndex) {
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 {
// Compress + encode photos for network send (limit payload)
final List<String> base64List = await _compressAndEncodePhotos(
photoData,
);
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) async {
try {
// Persist SVG only (no PNG)
await _databaseService.saveTaskSignature(task.id, svg);
} catch (e, stackTrace) {
developer.log(
'Error saving task signature: $e',
name: 'TaskView',
);
developer.log('Stack trace: $stackTrace', name: 'TaskView');
}
// 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,
},
);
},
),
),
);
}
void _showBarcodeDialog(BarcodeTask task) {
Navigator.of(context).push(
MaterialPageRoute(
builder:
(context) => BarcodeCaptureScreen(
task: task,
onBarcodesCompleted: (List<String> barcodes) async {
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,
}) {
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');
}
}
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) &&
!_skippedTasks.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;
final description = task.description;
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 (description?.isNotEmpty == true) {
return Text(description!, style: titleStyle);
}
// 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() => widget.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 widget.job.deliveryStations) {
if (station.stationOrder == stationOrder) {
final suffix =
station.displayName.isNotEmpty ? station.displayName : station.city;
return suffix.isNotEmpty
? 'Station ${stationOrder + 1}: $suffix'
: 'Station ${stationOrder + 1}';
}
}
return 'Station ${stationOrder + 1}';
}
}