Files
votianlt/app/lib/tasks/signature_capture_screen.dart
Sven Carstensen 6e8bedd9b4 feat: Drag-and-Drop-Reihenfolge, Station-Abschluss-Flow und UI-Verbesserungen
Lieferstationen-Dialog (Backend/Vaadin):
- Aufgaben per Drag & Drop neu anordnen, inkl. Drag-Handle, komprimierter
  Kachelansicht während des Drags und horizontaler Einfügelinie als Drop-Target
- Drop-Indikator wird unterdrückt, wenn der Drop keine Positionsänderung bewirken
  würde, und nach dem Abschluss clientseitig zuverlässig aufgeräumt
- Drag-Handle, Aufgabentyp-Label und Close-Button auf einheitlicher Position
  ausgerichtet; Abstände in der Kachel komprimiert

Station-Abschluss-Flow (Flutter-App + Backend):
- Neuer Button "Station abschließen" unter den Aufgaben; deaktiviert, solange
  Pflichtaufgaben offen sind, ansonsten aktiv (auch wenn nur optionale Aufgaben
  existieren)
- Hinweisdialog nach Erledigung der letzten Pflichtaufgabe sowie Warnung bei
  offenen optionalen Aufgaben vor dem Senden
- Neue station_completed-Nachricht (jobId, jobNumber, stationOrder,
  completedAt, hasIncompleteOptionalTasks) wird an den Server gesendet
- Backend: Auftrag wird nicht mehr automatisch beim Erledigen der letzten
  Pflichtaufgabe abgeschlossen, sondern erst beim Empfang der
  station_completed-Nachricht (neuer Handler in MessageController und
  MessagingConfig)

Aufgabenliste in der App:
- Farbcodierung optionaler Aufgaben entfernt; stattdessen vertikal zentrierter
  "Optional"-Chip am rechten Kartenrand

Weitere UI-Überarbeitungen über Login, Jobs, Chats, Settings, Aufgaben-Capture-
Screens, Offline-Banner und zugehörige Widgets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:26:30 +02:00

269 lines
8.4 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:signature/signature.dart';
import '../app_theme.dart';
import '../l10n/app_localizations.dart';
import '../models/tasks/signature_task.dart';
import '../widgets/offline_banner.dart';
class SignatureCaptureScreen extends StatefulWidget {
final SignatureTask task;
final void Function(String svg) onSignatureCompleted;
const SignatureCaptureScreen({
super.key,
required this.task,
required this.onSignatureCompleted,
});
@override
State<SignatureCaptureScreen> createState() => _SignatureCaptureScreenState();
}
class _SignatureCaptureScreenState extends State<SignatureCaptureScreen> {
late final SignatureController _controller;
bool _hasSignature = false;
bool _isMobilePlatform = false;
@override
void initState() {
super.initState();
_controller = SignatureController(
penStrokeWidth: 3,
penColor: Colors.black,
exportBackgroundColor: Colors.white,
);
// Listen to signature controller changes
_controller.addListener(_onSignatureChanged);
_detectPlatformAndSetOrientation();
}
void _onSignatureChanged() {
final bool hasPoints = _controller.points.isNotEmpty;
if (hasPoints != _hasSignature) {
setState(() {
_hasSignature = hasPoints;
});
}
}
void _detectPlatformAndSetOrientation() {
// Check if we're on a mobile platform
if (!kIsWeb) {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
_isMobilePlatform = true;
// Rotate screen 90 degrees to the right (landscape left)
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
]);
break;
default:
_isMobilePlatform = false;
}
}
}
void _restoreOrientation() {
// Restore original orientation when leaving the screen
if (_isMobilePlatform) {
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
}
}
@override
void dispose() {
_controller.removeListener(_onSignatureChanged);
_controller.dispose();
_restoreOrientation();
super.dispose();
}
String _buildSvgFromPoints(
List<Point?> points, {
double strokeWidth = 3.0,
String strokeColor = '#000000',
}) {
// Convert collected signature points (with null separators for stroke breaks) into an SVG string
// Determine bounds
double? minX, minY, maxX, maxY;
for (final p in points) {
if (p == null) continue;
final x = p.offset.dx;
final y = p.offset.dy;
if (minX == null || x < minX) minX = x;
if (minY == null || y < minY) minY = y;
if (maxX == null || x > maxX) maxX = x;
if (maxY == null || y > maxY) maxY = y;
}
// Fallback bounds if empty or degenerate
if (minX == null || minY == null || maxX == null || maxY == null) {
minX = 0;
minY = 0;
maxX = 1;
maxY = 1;
}
double width = (maxX - minX);
double height = (maxY - minY);
if (width <= 0) width = 1;
if (height <= 0) height = 1;
final StringBuffer d = StringBuffer();
bool newStroke = true;
for (final p in points) {
if (p == null) {
newStroke = true;
continue;
}
final x = (p.offset.dx - minX);
final y = (p.offset.dy - minY);
if (newStroke) {
d.write('M${x.toStringAsFixed(2)} ${y.toStringAsFixed(2)} ');
newStroke = false;
} else {
d.write('L${x.toStringAsFixed(2)} ${y.toStringAsFixed(2)} ');
}
}
final String svg =
'<svg xmlns="http://www.w3.org/2000/svg" width="${width.toStringAsFixed(2)}" height="${height.toStringAsFixed(2)}" viewBox="0 0 ${width.toStringAsFixed(2)} ${height.toStringAsFixed(2)}"><path d="${d.toString().trim()}" fill="none" stroke="$strokeColor" stroke-width="$strokeWidth" stroke-linecap="round" stroke-linejoin="round"/></svg>';
return svg;
}
Future<void> _finish() async {
try {
// Ensure there is at least one non-null point in the signature
final hasAnyPoint = _controller.points.isNotEmpty;
if (!hasAnyPoint) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).signatureRequired),
),
);
return;
}
// Build SVG from the captured signature points
final String svg = _buildSvgFromPoints(_controller.points);
// Close this screen first to show the updated TaskView quickly
if (!mounted) return;
_restoreOrientation();
Navigator.of(context).pop();
// Then notify the caller (SVG only)
widget.onSignatureCompleted(svg);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${AppLocalizations.of(context).signatureError}: $e'),
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(AppLocalizations.of(context).signatureCapture),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
_restoreOrientation();
Navigator.of(context).pop();
},
),
actions: [
IconButton(
tooltip: AppLocalizations.of(context).delete,
onPressed: () {
_controller.clear();
// The listener will automatically update _hasSignature when points are cleared
},
icon: const Icon(Icons.delete_outline),
),
],
),
body: Column(
children: [
OfflineBanner(),
Expanded(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context).signatureInstruction,
style: const TextStyle(color: AppColors.textMuted),
),
const SizedBox(height: 12),
Expanded(
child: Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.borderStrong),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Signature(
controller: _controller,
backgroundColor: AppColors.surface,
),
),
),
),
const SizedBox(height: 16),
Row(
children: [
OutlinedButton.icon(
onPressed: () {
_controller.clear();
// The listener will automatically update _hasSignature when points are cleared
},
icon: const Icon(Icons.refresh),
label: Text(AppLocalizations.of(context).clear),
),
const Spacer(),
SizedBox(
width: 160,
child: ElevatedButton(
onPressed: _hasSignature ? _finish : null,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: Text(AppLocalizations.of(context).finish),
),
),
],
),
],
),
),
),
],
),
);
}
}