Files
votianlt/app/lib/tasks/signature_capture_screen.dart
Sven Carstensen 704d1e7378 feat: Adressbuch mit Kundennummer, Update-Flow und interne Einträge
- Menüpunkt "Kunden" in "Adressbuch" umbenannt und App-Label
  "Verfügbare Jobs" zu "Auftragsliste" geändert (alle 10 Sprachen)
- Fortlaufende Kundennummer (usrId) ab 10000 über neuen
  SequenceGeneratorService und Counter-Dokument in misc-Collection
- Abholung/Lieferstation-Dialog: Änderungen an verknüpften
  Stammdaten aktualisieren den bestehenden Adressbuch-Eintrag
  statt einen neuen zu erzeugen; Checkbox-Label wechselt zu
  "Adresse im Adressbuch aktualisieren"
- Geänderte Adressen ohne Checkbox werden als interner Customer
  (internal=true) gesichert und im Adressbuch ausgeblendet
- E-Mail in AddCustomer und in Stations-Dialogen kein Pflichtfeld
  mehr; "(Login)" aus profile.email entfernt
- Manuelles Beenden eines Auftrags öffnet neue Seite
  JobManualCompleteView statt eines Dialogs
2026-04-20 12:42:56 +02:00

282 lines
9.0 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, String note) 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;
final TextEditingController _noteController = TextEditingController();
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();
_noteController.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);
final String note = _noteController.text.trim();
// Close this screen first to show the updated TaskView quickly
if (!mounted) return;
_restoreOrientation();
Navigator.of(context).pop();
// Then notify the caller (SVG + Bemerkung)
widget.onSignatureCompleted(svg, note);
} 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: 12),
TextField(
controller: _noteController,
decoration: InputDecoration(
labelText: AppLocalizations.of(context).completeTaskNote,
border: const OutlineInputBorder(),
),
maxLines: 2,
minLines: 1,
),
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),
),
),
],
),
],
),
),
),
],
),
);
}
}